From 696fb1bcb310e4893bdb9c84805970974dfe1328 Mon Sep 17 00:00:00 2001 From: Sean Huang Date: Fri, 23 Oct 2026 18:38:49 +0000 Subject: [PATCH 001/287] Move the V8 Bazel build graph into Codex (#57164) ## What changed - Replace the upstream V8 module and dependency bridge with a source repository rule and `third_party/v8/engine.BUILD.bazel`, wiring dependencies and Python generator inputs directly from Codex. - Import source filegroups from the pinned upstream BUILD, rejecting missing boundaries, malformed sections, and unexpected compiled targets. - Define fixed source feature selections in the engine BUILD while retaining release ABI, runtime, and CPU settings. Reuse upstream platform configuration and limit the build integration patch to `bazel/defs.bzl`. - Document source inventory review and canary validation for V8 upgrades. GitOrigin-RevId: 1b42672b77fb8f0f34c41ae3345c0e302a892d44 --- MODULE.bazel | 38 +- patches/BUILD.bazel | 2 - patches/v8_bazel_rules.patch | 456 ------------- patches/v8_module_deps.patch | 231 ------- third_party/v8/BUILD.bazel | 4 + third_party/v8/README.md | 29 +- third_party/v8/engine.BUILD.bazel | 933 +++++++++++++++++++++++++++ third_party/v8/engine_repository.bzl | 93 +++ third_party/v8/engine_rules.bzl | 13 + third_party/v8/v8_defs.patch | 176 +++++ 10 files changed, 1260 insertions(+), 715 deletions(-) delete mode 100644 patches/v8_bazel_rules.patch delete mode 100644 patches/v8_module_deps.patch create mode 100644 third_party/v8/engine.BUILD.bazel create mode 100644 third_party/v8/engine_repository.bzl create mode 100644 third_party/v8/engine_rules.bzl create mode 100644 third_party/v8/v8_defs.patch diff --git a/MODULE.bazel b/MODULE.bazel index b4bcc6f99435..3ffde1809c4a 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -807,25 +807,36 @@ inject_repo(crate, "alsa_lib") ] ] -bazel_dep(name = "v8", version = "15.2.124.1") bazel_dep(name = "rules_license", version = "1.0.0") -archive_override( - module_name = "v8", +engine_repository = use_repo_rule("//third_party/v8:engine_repository.bzl", "engine_repository") + +engine_repository( + name = "v8", integrity = "sha256-aNN5G427qQbP/5o+NXONgXR8ZvS6mjf6mfAI6jtxyzI=", patch_strip = 3, patches = [ - "//patches:v8_module_deps.patch", - "//patches:v8_bazel_rules.patch", "//patches:v8_source_portability.patch", "//patches:v8_array_sort_elements_kind.patch", + "//third_party/v8:v8_defs.patch", ], strip_prefix = "v8-15.2.124.1", urls = ["https://github.com/v8/v8/archive/refs/tags/15.2.124.1.tar.gz"], ) -# The dependency repos and their BUILD files belong to Codex. V8 only needs -# its original apparent repo names to resolve to these same imports. +# All V8 inputs are owned here, including the pinned Python generator inputs. +pip.parse( + extra_pip_args = ["--require-hashes"], + hub_name = "v8_python_deps", + python_version = "3.11", + requirements_lock = "@v8//:bazel/requirements.txt", + simpleapi_skip = [ + "jinja2", + "markupsafe", + ], +) +use_repo(pip, "v8_python_deps") + v8_dependencies = use_extension("//third_party/v8:dependencies.bzl", "dependencies") use_repo( v8_dependencies, @@ -838,19 +849,6 @@ use_repo( "v8_simdutf", ) -v8_dependency_bridge = use_extension("@v8//:bazel/codex_deps.bzl", "codex_deps") - -inject_repo( - v8_dependency_bridge, - dragonbox = "v8_dragonbox", - fast_float = "v8_fast_float", - fp16 = "v8_fp16", - highway = "v8_highway", - icu = "v8_icu", - simdutf = "v8_simdutf", - v8_abseil = "v8_abseil", -) - http_archive( name = "v8_crate_152_2_0", build_file = "//third_party/v8:v8_crate.BUILD.bazel", diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index 039167a73860..1c62ed89f26b 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -25,8 +25,6 @@ exports_files([ "tikv-jemalloc-sys_make_env.patch", "tikv-jemalloc-sys_dependency_flags.patch", "v8_array_sort_elements_kind.patch", - "v8_bazel_rules.patch", - "v8_module_deps.patch", "v8_source_portability.patch", "webrtc-sys_hermetic_darwin_sysroot.patch", "windows-link.patch", diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch deleted file mode 100644 index bcdb9deab5bd..000000000000 --- a/patches/v8_bazel_rules.patch +++ /dev/null @@ -1,456 +0,0 @@ -# What: adapt upstream V8 Bazel rules to this workspace's hermetic toolchains -# and externally provided dependencies. -# Scope: Bazel BUILD/defs integration only, including dependency -# wiring, generated sources, and visibility; no standalone V8 source patching. - -diff --git a/orig/v8-15.2.124.1/bazel/defs.bzl b/mod/v8-15.2.124.1/bazel/defs.bzl ---- a/orig/v8-15.2.124.1/bazel/defs.bzl -+++ b/mod/v8-15.2.124.1/bazel/defs.bzl -@@ -42,12 +42,30 @@ - ) - - def v8_flag(name, default = False): -- _create_option_flag(name = name, build_setting_default = default, scope = "universal") -- native.config_setting(name = "is_" + name, flag_values = {name: "True"}) -- native.config_setting(name = "is_not_" + name, flag_values = {name: "False"}) -- --def v8_string(name, default = ""): -- _create_option_string(name = name, build_setting_default = default, scope = "universal") -+ _create_option_flag( -+ name = name, -+ build_setting_default = default, -+ scope = "universal", -+ visibility = ["//visibility:public"], -+ ) -+ native.config_setting( -+ name = "is_" + name, -+ flag_values = {name: "True"}, -+ visibility = ["//visibility:public"], -+ ) -+ native.config_setting( -+ name = "is_not_" + name, -+ flag_values = {name: "False"}, -+ visibility = ["//visibility:public"], -+ ) -+ -+def v8_string(name, default = "", visibility = None): -+ _create_option_string( -+ name = name, -+ build_setting_default = default, -+ scope = "universal", -+ visibility = visibility, -+ ) - - def v8_int(name, default = 0): - _create_option_int(name = name, build_setting_default = default, scope = "universal") -@@ -106,7 +124,13 @@ - - def _default_args(): - return struct( -- deps = [":define_flags", "@libcxx//:libc++"], -+ deps = [":define_flags"] + select({ -+ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ -+ "@@//third_party/v8:rusty_v8_custom_libcxx_headers", -+ "@@//third_party/v8:rusty_v8_custom_libcxx_runtime", -+ ], -+ "//conditions:default": [], -+ }), - defines = select({ - "@v8//bazel/config:is_windows": [ - "UNICODE", -@@ -136,12 +160,15 @@ - ], - "//conditions:default": [], - }) + select({ -- "@v8//bazel/config:is_clang": [ -- "-Wno-invalid-offsetof", -- "-Wno-deprecated-this-capture", -- "-Wno-deprecated-declarations", -- "-std=c++20", -- ], -+ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ -+ "-nostdinc++", -+ "-D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", -+ "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", -+ "-D_LIBCPP_INSTRUMENTED_WITH_ASAN=0", -+ "-D_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", -+ ], -+ "//conditions:default": [], -+ }) + select({ - "@v8//bazel/config:is_gcc": [ - "-Wno-extra", - "-Wno-array-bounds", -@@ -161,9 +188,17 @@ - "-std=gnu++2a", - ], - "@v8//bazel/config:is_windows": [ -- "/std:c++20", -- ], -- "//conditions:default": [], -+ "-Wno-invalid-offsetof", -+ "-Wno-deprecated-this-capture", -+ "-Wno-deprecated-declarations", -+ "-std=c++20", -+ ], -+ "//conditions:default": [ -+ "-Wno-invalid-offsetof", -+ "-Wno-deprecated-this-capture", -+ "-Wno-deprecated-declarations", -+ "-std=c++20", -+ ], - }) + select({ - "@v8//bazel/config:is_gcc_fastbuild": [ - # Non-debug builds without optimizations fail because -@@ -187,12 +222,12 @@ - includes = ["include"], - linkopts = select({ - "@v8//bazel/config:is_windows": [ -- "Winmm.lib", -- "DbgHelp.lib", -- "Advapi32.lib", -+ "-lwinmm", -+ "-ldbghelp", -+ "-ladvapi32", - ], - "@v8//bazel/config:is_macos": ["-pthread"], -- "//conditions:default": ["-Wl,--no-as-needed -ldl -latomic -pthread"], -+ "//conditions:default": ["-Wl,--no-as-needed -ldl -pthread"], - }) + select({ - ":should_add_rdynamic": ["-rdynamic"], - "//conditions:default": [], -@@ -466,6 +501,11 @@ - def _mksnapshot(ctx): - prefix = ctx.attr.prefix - suffix = ctx.attr.suffix -+ # Windows cross-builds use Linux for the exec configuration, but the -+ # snapshot generator must match the target ABI and run on the Windows -+ # runner. Action strategies only choose where an action runs; they cannot -+ # change this executable from the exec to the target configuration. -+ tool = ctx.executable.target_tool if ctx.attr.target_os == "win" else ctx.executable.tool - outs = [ - ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), - ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), -@@ -483,7 +523,7 @@ - "--embedded_src", - outs[1].path, - ] + ctx.attr.args, -- executable = ctx.executable.tool, -+ executable = tool, - progress_message = "Running mksnapshot", - ) - return [DefaultInfo(files = depset(outs))] -@@ -498,6 +538,12 @@ - executable = True, - cfg = "exec", - ), -+ "target_tool": attr.label( -+ mandatory = True, -+ allow_files = True, -+ executable = True, -+ cfg = "target", -+ ), - "target_os": attr.string(mandatory = True), - "prefix": attr.string(mandatory = True), - "suffix": attr.string(mandatory = True), -@@ -511,6 +557,7 @@ - args = args, - prefix = "noicu", - tool = ":noicu/mksnapshot" + suffix, -+ target_tool = ":noicu/mksnapshot" + suffix, - suffix = suffix, - target_os = select({ - "@v8//bazel/config:is_macos": "mac", -@@ -523,6 +570,7 @@ - args = args, - prefix = "icu", - tool = ":icu/mksnapshot" + suffix, -+ target_tool = ":icu/mksnapshot" + suffix, - suffix = suffix, - target_os = select({ - "@v8//bazel/config:is_macos": "mac", -diff --git a/orig/v8-15.2.124.1/BUILD.bazel b/mod/v8-15.2.124.1/BUILD.bazel ---- a/orig/v8-15.2.124.1/BUILD.bazel -+++ b/mod/v8-15.2.124.1/BUILD.bazel -@@ -154,6 +154,10 @@ - - v8_flag(name = "v8_enable_v8_checks") - -+v8_flag(name = "v8_enable_sandbox") -+ -+v8_flag(name = "v8_use_rusty_v8_custom_libcxx") -+ - v8_flag(name = "v8_enable_verify_csa") - - v8_flag(name = "v8_enable_verify_heap") -@@ -319,7 +323,8 @@ - # If no explicit value for v8_enable_pointer_compression, we set it to 'none'. - v8_string( - name = "v8_enable_pointer_compression", -- default = "none", -+ default = "False", -+ visibility = ["//visibility:public"], - ) - - # Default setting for v8_enable_pointer_compression. -@@ -520,6 +525,7 @@ - "v8_enable_slow_dchecks": "ENABLE_SLOW_DCHECKS", - "v8_enable_runtime_call_stats": "V8_RUNTIME_CALL_STATS", - "v8_enable_snapshot_native_code_counters": "V8_SNAPSHOT_NATIVE_CODE_COUNTERS", -+ "v8_enable_sandbox": "V8_ENABLE_SANDBOX", - "v8_enable_trace_maps": "V8_TRACE_MAPS", - "v8_enable_turbofan": "V8_ENABLE_TURBOFAN", - "v8_enable_v8_checks": "V8_ENABLE_CHECKS", -@@ -4170,28 +4176,14 @@ - }), - ) - --v8_library( -- name = "lib_dragonbox", -- srcs = ["third_party/dragonbox/src/include/dragonbox/dragonbox.h"], -- hdrs = [ -- "third_party/dragonbox/src/include/dragonbox/dragonbox.h", -- ], -- includes = [ -- "third_party/dragonbox/src/include", -- ], --) -- --v8_library( -- name = "lib_fp16", -- srcs = ["third_party/fp16/src/include/fp16.h"], -- hdrs = [ -- "third_party/fp16/src/include/fp16/fp16.h", -- "third_party/fp16/src/include/fp16/bitcasts.h", -- "third_party/fp16/src/include/fp16/macros.h", -- ], -- includes = [ -- "third_party/fp16/src/include", -- ], -+alias( -+ name = "lib_dragonbox", -+ actual = "@dragonbox//:dragonbox", -+) -+ -+alias( -+ name = "lib_fp16", -+ actual = "@fp16//:fp16", - ) - - filegroup( -@@ -4486,6 +4478,20 @@ - srcs = [ - "include/js_protocol.pdl", - "src/inspector/inspector_protocol_config.json", -+ "third_party/inspector_protocol/code_generator.py", -+ "third_party/inspector_protocol/pdl.py", -+ "third_party/inspector_protocol/lib/Forward_h.template", -+ "third_party/inspector_protocol/lib/Object_cpp.template", -+ "third_party/inspector_protocol/lib/Object_h.template", -+ "third_party/inspector_protocol/lib/Protocol_cpp.template", -+ "third_party/inspector_protocol/lib/ValueConversions_cpp.template", -+ "third_party/inspector_protocol/lib/ValueConversions_h.template", -+ "third_party/inspector_protocol/lib/Values_cpp.template", -+ "third_party/inspector_protocol/lib/Values_h.template", -+ "third_party/inspector_protocol/templates/Exported_h.template", -+ "third_party/inspector_protocol/templates/Imported_h.template", -+ "third_party/inspector_protocol/templates/TypeBuilder_cpp.template", -+ "third_party/inspector_protocol/templates/TypeBuilder_h.template", - ], - outs = [ - "include/inspector/Debugger.h", -@@ -4507,15 +4513,19 @@ - "src/inspector/protocol/Schema.cpp", - "src/inspector/protocol/Schema.h", - ], -- cmd = "$(location :code_generator) --jinja_dir . \ -- --inspector_protocol_dir third_party/inspector_protocol \ -+ cmd = "INSPECTOR_PROTOCOL_DIR=$$(dirname $(execpath third_party/inspector_protocol/code_generator.py)); \ -+ PYTHONPATH=$$INSPECTOR_PROTOCOL_DIR:external/rules_python++pip+v8_python_deps_311_jinja2/site-packages:external/rules_python++pip+v8_python_deps_311_markupsafe/site-packages:$${PYTHONPATH-} \ -+ $(execpath @rules_python//python/bin:python) $(execpath third_party/inspector_protocol/code_generator.py) --jinja_dir . \ -+ --inspector_protocol_dir $$INSPECTOR_PROTOCOL_DIR \ - --config $(location :src/inspector/inspector_protocol_config.json) \ - --config_value protocol.path=$(location :include/js_protocol.pdl) \ -+ --config_value crdtp.dir=third_party/inspector_protocol/crdtp \ - --output_base $(@D)/src/inspector", -- local = 1, - message = "Generating inspector files", - tools = [ -- ":code_generator", -+ "@rules_python//python/bin:python", -+ requirement("jinja2"), -+ requirement("markupsafe"), - ], - ) - -@@ -4527,6 +4537,35 @@ - ":v8_libsampler_files", - ":v8_shared_internal_headers", - ], -+) -+ -+cc_library( -+ name = "rusty_v8_internal_headers", -+ hdrs = [ -+ "src/libplatform/default-platform.h", -+ ], -+ strip_include_prefix = "", -+ visibility = ["//visibility:public"], -+) -+ -+cc_library( -+ name = "rusty_v8_crdtp_headers", -+ hdrs = [ -+ "third_party/inspector_protocol/crdtp/cbor.h", -+ "third_party/inspector_protocol/crdtp/dispatch.h", -+ "third_party/inspector_protocol/crdtp/error_support.h", -+ "third_party/inspector_protocol/crdtp/export.h", -+ "third_party/inspector_protocol/crdtp/find_by_first.h", -+ "third_party/inspector_protocol/crdtp/frontend_channel.h", -+ "third_party/inspector_protocol/crdtp/json.h", -+ "third_party/inspector_protocol/crdtp/parser_handler.h", -+ "third_party/inspector_protocol/crdtp/protocol_core.h", -+ "third_party/inspector_protocol/crdtp/serializable.h", -+ "third_party/inspector_protocol/crdtp/span.h", -+ "third_party/inspector_protocol/crdtp/status.h", -+ ], -+ strip_include_prefix = "", -+ visibility = ["//visibility:public"], - ) - - filegroup( -@@ -4609,11 +4648,9 @@ - strip_include_prefix = "noicu", - ) - --cc_library( -+alias( - name = "llvm_libc_headers", -- hdrs = glob(["third_party/llvm-libc/src/shared/**/*.h"]), -- includes = ["third_party/llvm-libc/src"], -- defines = ["LIBC_NAMESPACE=__llvm_libc_cr"], -+ actual = "@@//third_party/v8:rusty_v8_llvm_libc_headers", - ) - - v8_library( -@@ -4625,9 +4662,9 @@ - copts = ["-Wno-implicit-fallthrough"], - deps = [ - ":llvm_libc_headers", -- "@abseil-cpp//absl/synchronization", -- "@abseil-cpp//absl/time", -- "@abseil-cpp//absl/functional:overload", -+ "@v8_abseil//:synchronization", -+ "@v8_abseil//:time", -+ "@v8_abseil//:overload", - ], - ) - -@@ -4655,21 +4692,14 @@ - deps = [ - ":torque_base_headers", - ":v8_libbase", -- "@abseil-cpp//absl/container:node_hash_map", -- "@abseil-cpp//absl/container:node_hash_set", -- ], --) -- --cc_library( -- name = "simdutf", -- srcs = ["third_party/simdutf/simdutf.cpp"], -- hdrs = ["third_party/simdutf/simdutf.h"], -- copts = select({ -- "@v8//bazel/config:is_clang": ["-std=c++20"], -- "@v8//bazel/config:is_gcc": ["-std=gnu++2a"], -- "@v8//bazel/config:is_windows": ["/std:c++20"], -- "//conditions:default": [], -- }), -+ "@v8_abseil//:node_hash_map", -+ "@v8_abseil//:node_hash_set", -+ ], -+) -+ -+alias( -+ name = "simdutf", -+ actual = "@simdutf//:simdutf", - ) - - v8_library( -@@ -4686,7 +4716,7 @@ - copts = ["-Wno-implicit-fallthrough"], - icu_deps = [ - ":icu/generated_torque_definitions_headers", -- "//external:icu", -+ "@icu//:icu", - ], - icu_srcs = [ - ":generated_regexp_special_case", -@@ -4701,14 +4731,14 @@ - ], - deps = [ - ":lib_dragonbox", -- "//third_party/fast_float/src:fast_float", -+ "@fast_float//:fast_float", - ":lib_fp16", - ":simdutf", - ":v8_libbase", -- "@abseil-cpp//absl/container:btree", -- "@abseil-cpp//absl/container:flat_hash_map", -- "@abseil-cpp//absl/container:flat_hash_set", -- "@abseil-cpp//absl/functional:overload", -+ "@v8_abseil//:btree", -+ "@v8_abseil//:flat_hash_map", -+ "@v8_abseil//:flat_hash_set", -+ "@v8_abseil//:overload", - "@highway//:hwy", - ], - ) -@@ -4757,6 +4787,7 @@ - alias( - name = "core_lib_icu", - actual = "icu/v8", -+ visibility = ["//visibility:public"], - ) - - v8_library( -@@ -4808,7 +4839,7 @@ - ], - deps = [ - ":v8_libbase", -- "//external:icu", -+ "@icu//:icu", - ], - ) - -@@ -4830,8 +4861,8 @@ - }), - deps = [ - "v8_libbase", -- "@abseil-cpp//absl/container:node_hash_map", -- "@abseil-cpp//absl/container:node_hash_set", -+ "@v8_abseil//:node_hash_map", -+ "@v8_abseil//:node_hash_set", - ], - ) - -@@ -4852,9 +4883,20 @@ - ":icu/generated_torque_initializers", - ":icu/v8_initializers_files", - ], -+ # Match GN's mksnapshot `disable_icf` config. If the linker folds distinct -+ # external-reference helpers together while producing the snapshot, the -+ # final embedder binary may not fold the same pair and startup -+ # deserialization will reject the snapshot. -+ # GN also increases the x64 Windows stack reserve because constructing the -+ # snapshot overflows the linker's 1 MiB default. - linkopts = select({ - "@v8//bazel/config:is_android": ["-llog"], -- "//conditions:default": [], -+ "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], -+ "@v8//bazel/config:is_windows": [ -+ "-Wl,/OPT:NOICF", -+ "-Wl,/STACK:2097152", -+ ], -+ "//conditions:default": ["-Wl,--icf=none"], - }), - noicu_deps = [":v8_libshared_noicu"], - noicu_srcs = [ diff --git a/patches/v8_module_deps.patch b/patches/v8_module_deps.patch deleted file mode 100644 index b0ce2f62987c..000000000000 --- a/patches/v8_module_deps.patch +++ /dev/null @@ -1,231 +0,0 @@ -# What: remove Chromium's checkout-only toolchain bootstrapping and expose one -# module-extension bridge for dependency imports owned by Codex. -# Scope: V8 MODULE and the small ingress extension only. Versions, BUILD files, -# and dependency build policy live in //third_party/v8. - -diff --git a/orig/v8-15.2.124.1/MODULE.bazel b/mod/v8-15.2.124.1/MODULE.bazel ---- a/orig/v8-15.2.124.1/MODULE.bazel -+++ b/mod/v8-15.2.124.1/MODULE.bazel -@@ -7,13 +7,13 @@ - bazel_dep(name = "rules_cc", version = "0.2.17") - bazel_dep(name = "rules_python", version = "1.7.0") - bazel_dep(name = "platforms", version = "1.0.0") --bazel_dep(name = "abseil-cpp", version = "20250814.1") --bazel_dep(name = "highway", version = "1.2.0") - - pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") - pip.parse( - hub_name = "v8_python_deps", - python_version = "3.11", -+ # Use the pip resolver to preserve V8's hashes and repository names. -+ simpleapi_skip = ["jinja2", "markupsafe"], - requirements_lock = "//:bazel/requirements.txt", - extra_pip_args = [ - "--require-hashes", -@@ -22,179 +22,15 @@ - ) - use_repo(pip, "v8_python_deps") - --# Define the local LLVM toolchain repository --llvm_toolchain_repository = use_repo_rule("//bazel/toolchain:llvm_repository.bzl", "llvm_toolchain_repository") -- --llvm_toolchain_repository( -- name = "llvm_toolchain", -- path = "third_party/llvm-build/Release+Asserts", -- config_file_content = """ --load("@rules_cc//cc:defs.bzl", "CcToolchainConfigInfo", "cc_common") --load("@bazel_tools//tools/cpp:cc_toolchain_config_lib.bzl", "feature", "flag_group", "flag_set", "tool_path") -- --def _impl(ctx): -- tool_paths = [ -- tool_path(name = "gcc", path = "bin/clang"), -- tool_path(name = "ld", path = "bin/lld"), -- tool_path(name = "ar", path = "bin/llvm-ar"), -- tool_path(name = "cpp", path = "bin/clang++"), -- tool_path(name = "gcov", path = "/bin/false"), -- tool_path(name = "nm", path = "bin/llvm-nm"), -- tool_path(name = "objdump", path = "bin/llvm-objdump"), -- tool_path(name = "strip", path = "bin/llvm-strip"), -- ] -- -- features = [ -- feature( -- name = "default_compile_flags", -- enabled = True, -- flag_sets = [ -- flag_set( -- actions = [ -- "c-compile", -- "c++-compile", -- "c++-header-parsing", -- "c++-module-compile", -- "c++-module-codegen", -- "linkstamp-compile", -- "assemble", -- "preprocess-assemble", -- ], -- flag_groups = [ -- flag_group( -- flags = [ -- "--sysroot={WORKSPACE_ROOT}/build/linux/debian_bullseye_amd64-sysroot", -- "-nostdinc++", -- "-isystem", -- "{WORKSPACE_ROOT}/buildtools/third_party/libc++", -- "-isystem", -- "{WORKSPACE_ROOT}/third_party/libc++/src/include", -- "-isystem", -- "{WORKSPACE_ROOT}/third_party/libc++abi/src/include", -- "-isystem", -- "{WORKSPACE_ROOT}/third_party/libc++/src/src", -- "-isystem", -- "{WORKSPACE_ROOT}/third_party/llvm-libc/src", -- "-D_LIBCPP_HARDENING_MODE_DEFAULT=_LIBCPP_HARDENING_MODE_NONE", -- "-DLIBC_NAMESPACE=__llvm_libc_cr", -- ], -- ), -- ], -- ), -- ], -- ), -- feature( -- name = "default_linker_flags", -- enabled = True, -- flag_sets = [ -- flag_set( -- actions = [ -- "c++-link-executable", -- "c++-link-dynamic-library", -- "c++-link-nodeps-dynamic-library", -- ], -- flag_groups = [ -- flag_group( -- flags = [ -- "--sysroot={WORKSPACE_ROOT}/build/linux/debian_bullseye_amd64-sysroot", -- "-fuse-ld=lld", -- "-lm", -- "-lpthread", -- ], -- ), -- ], -- ), -- ], -- ), -- ] -- -- return cc_common.create_cc_toolchain_config_info( -- ctx = ctx, -- toolchain_identifier = "local_clang", -- features = features, -- cxx_builtin_include_directories = [ -- "{WORKSPACE_ROOT}/buildtools/third_party/libc++", -- "{WORKSPACE_ROOT}/third_party/libc++/src/include", -- "{WORKSPACE_ROOT}/third_party/libc++abi/src/include", -- "{WORKSPACE_ROOT}/third_party/libc++/src/src", -- "{WORKSPACE_ROOT}/third_party/llvm-libc/src", -- "{WORKSPACE_ROOT}/third_party/llvm-build/Release+Asserts/lib/clang/22/include", -- "{WORKSPACE_ROOT}/third_party/llvm-build/Release+Asserts/lib/clang/23/include", -- "{WORKSPACE_ROOT}/build/linux/debian_bullseye_amd64-sysroot/usr/include", -- "{WORKSPACE_ROOT}/build/linux/debian_bullseye_amd64-sysroot/usr/local/include", -- ], -- host_system_name = "local", -- target_system_name = "local", -- target_cpu = "k8", -- target_libc = "unknown", -- compiler = "clang", -- abi_version = "unknown", -- abi_libc_version = "unknown", -- tool_paths = tool_paths, -- ) -- --cc_toolchain_config = rule( -- implementation = _impl, -- attrs = {}, -- provides = [CcToolchainConfigInfo], -+# Codex supplies the dependency repositories used by its V8 builds. -+codex_deps = use_extension("//:bazel/codex_deps.bzl", "codex_deps") -+use_repo( -+ codex_deps, -+ "v8_abseil", -+ "dragonbox", -+ "fast_float", -+ "fp16", -+ "highway", -+ "icu", -+ "simdutf", - ) --""", -- build_file_content = """ --load("@rules_cc//cc:defs.bzl", "cc_toolchain") --load(":cc_toolchain_config.bzl", "cc_toolchain_config") -- --package(default_visibility = ["//visibility:public"]) -- --filegroup( -- name = "all_files", -- srcs = glob(["**/*"]), --) -- --filegroup(name = "empty") -- --cc_toolchain_config(name = "k8_toolchain_config") -- --cc_toolchain( -- name = "k8_toolchain", -- all_files = ":all_files", -- ar_files = ":all_files", -- compiler_files = ":all_files", -- dwp_files = ":empty", -- linker_files = ":all_files", -- objcopy_files = ":all_files", -- strip_files = ":all_files", -- supports_param_files = 0, -- toolchain_config = ":k8_toolchain_config", -- toolchain_identifier = "local_clang", --) -- --toolchain( -- name = "cc_toolchain_k8", -- exec_compatible_with = [ -- "@platforms//cpu:x86_64", -- "@platforms//os:linux", -- ], -- target_compatible_with = [ -- "@platforms//cpu:x86_64", -- "@platforms//os:linux", -- ], -- toolchain = ":k8_toolchain", -- toolchain_type = "@bazel_tools//tools/cpp:toolchain_type", --) --""", --) -- --register_toolchains("@llvm_toolchain//:cc_toolchain_k8") -- --# Define local repository for libc++ from third_party sources --libcxx_repository = use_repo_rule("//bazel/toolchain:libcxx_repository.bzl", "libcxx_repository") -- --libcxx_repository( -- name = "libcxx", --) -- --single_version_override( -- module_name = "highway", -- patches = ["//bazel/patches:highway_rules_cc.patch"], -- patch_strip = 1, --) - -diff --git a/orig/v8-15.2.124.1/bazel/codex_deps.bzl b/mod/v8-15.2.124.1/bazel/codex_deps.bzl -new file mode 100644 ---- /dev/null -+++ b/mod/v8-15.2.124.1/bazel/codex_deps.bzl -@@ -0,0 +1,10 @@ -+"""Dependency ingress for V8 when built by Codex. -+ -+The root module injects the repositories; this extension declares no defaults -+that could silently select a different dependency from the one Codex builds. -+""" -+ -+def _codex_deps_impl(module_ctx): -+ return module_ctx.extension_metadata(reproducible = True) -+ -+codex_deps = module_extension(implementation = _codex_deps_impl) diff --git a/third_party/v8/BUILD.bazel b/third_party/v8/BUILD.bazel index 333a608f372d..ebc2a802be9e 100644 --- a/third_party/v8/BUILD.bazel +++ b/third_party/v8/BUILD.bazel @@ -10,11 +10,15 @@ exports_files([ "dependencies.bzl", "dependency_config.bzl", "dragonbox.BUILD.bazel", + "engine.BUILD.bazel", + "engine_repository.bzl", + "engine_rules.bzl", "fast_float.BUILD.bazel", "fp16.BUILD.bazel", "highway.BUILD.bazel", "icu.BUILD.bazel", "simdutf.BUILD.bazel", + "v8_defs.patch", ]) config_setting( diff --git a/third_party/v8/README.md b/third_party/v8/README.md index b9659d957e55..9c60403e9845 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -35,6 +35,10 @@ Use this as the maintainer flow for a version bump: 2. Update the Bazel versioned inputs in `MODULE.bazel` and the source pins in `third_party/v8/dependencies.bzl` and `abseil_repository.bzl` using [the source dependency update procedure](#updating-source-dependencies). + Verify the imported source groups by querying the source archive and generating Torque + and snapshots for each supported platform. If V8 changes its BUILD section + layout, the importer fails instead of silently using an old inventory; + update that boundary and review its fixed feature and CPU selections. Then refresh the matching checksum manifest and generated checksums below. 3. Publish a release-candidate PR and validate that `v8-canary` passes. 4. If the canary is green, publish the release tag and release build. @@ -147,10 +151,20 @@ here declare the targets V8 uses. Abseil, Highway and simdutf apply their C++ ru settings through `dependency_config.bzl`; ICU defines its settings in its own overlay. -V8's module has a small dependency ingress. The root `MODULE.bazel` injects -the seven Codex repositories there, so V8's `@highway`, `@icu`, etc. resolve to -the same repositories Codex declares. The bridge has no fallback downloads. -V8's BUILD patch still wires these targets into its generator and library graph. +`engine_repository.bzl` fetches the V8 source pin and installs Codex's engine +BUILD into `@v8`. Source filegroups are read from the pinned upstream BUILD +each fetch, including added, removed or reordered groups. The importer rejects +missing boundaries, malformed or empty sections, and new compiled targets. Only +the two known Dragonbox and FP16 declarations are skipped, since the dependency +overlays already supply them. `v8_binary` is valid V8 syntax, but compiled targets +must be reviewed against the owned engine BUILD on an upgrade. The +upstream `bazel/config` platform and CPU package is reused unchanged. +`v8_defs.patch` records just the Codex differences to upstream's +`bazel/defs.bzl`: release flag visibility, libc++ headers/runtime, Windows +compiler and linker settings, and snapshot generation. All engine rules, +including Torque and V8's test configuration helpers, load from that file. +The engine BUILD owns feature policy and dependency targets; the remaining V8 +ABI, runtime and CPU settings still support the existing release commands. V8's private Abseil is built from the pinned 20250814.1 sources and uses `codex_v8_lts_20250814` for its supported inline-namespace option. Protobuf @@ -165,9 +179,12 @@ autoupdater: the prebuilt checksum commands above and their CI check validate published archive/binding pairs, not these source revisions. Bazel verifies the declared download hashes and Git revisions; it does not choose them for us. -Use the V8 tag in the root `MODULE.bazel` `archive_override(module_name = "v8")` +Use the V8 tag in the root `MODULE.bazel` `engine_repository(name = "v8")` (currently `15.2.124.1`) as the input. Read that tag's **unpatched** `DEPS` -and `MODULE.bazel`; the fetched `@v8` MODULE has Codex's bridge applied. +and `MODULE.bazel`; `@v8` deliberately does not include upstream's MODULE. +On an upgrade, also review `v8_defs.patch` against the new `bazel/defs.bzl` +and run the V8 source canary. A cleanly applying patch does not by itself +verify new upstream defaults or code-generation behavior. ```bash v8_tag=15.2.124.1 # Replace with the V8 source tag selected for this upgrade. diff --git a/third_party/v8/engine.BUILD.bazel b/third_party/v8/engine.BUILD.bazel new file mode 100644 index 000000000000..dba5eca64d24 --- /dev/null +++ b/third_party/v8/engine.BUILD.bazel @@ -0,0 +1,933 @@ +# Copyright 2021 the V8 project authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +load("@@//third_party/v8:engine_rules.bzl", "v8_fixed_source_features") +load("@bazel_skylib//lib:selects.bzl", "selects") +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_python//python:defs.bzl", "py_binary", "py_test") +load( + "@v8//:bazel/defs.bzl", + "v8_binary", + "v8_build_config", + "v8_config", + "v8_flag", + "v8_library", + "v8_mksnapshot", + "v8_string", + "v8_torque_files", +) +load("@v8//:bazel/v8-non-pointer-compression.bzl", "v8_binary_non_pointer_compression") +load("@v8_python_deps//:requirements.bzl", "requirement") + +# Used by the Codex-owned pip hub; pin changes travel with the V8 source. +exports_files(["bazel/requirements.txt"]) + +# Codex V8 source policy. Only ABI, runtime and target CPU vary; these still +# accept the existing release inputs. CPU selection lives in bazel/config. +# Upstream experiments, profiling and debug flags use their pinned 15.2 defaults. +# ICU and no-ICU remain separate targets for the engine and its generators. +v8_flag(name = "v8_enable_sandbox") + +v8_flag(name = "v8_use_rusty_v8_custom_libcxx") + +# Maglev is supported on the two architectures shipped by Codex. Keep the +# architecture tests separate from feature policy for host-tool transitions. +selects.config_setting_group( + name = "enable_maglev", + match_any = [ + "@v8//bazel/config:v8_target_x64", + "@v8//bazel/config:v8_target_arm64", + ], +) + +selects.config_setting_group( + name = "enable_maglev_x64", + match_all = [ + ":enable_maglev", + "@v8//bazel/config:v8_target_x64", + ], +) + +selects.config_setting_group( + name = "enable_maglev_arm64", + match_all = [ + ":enable_maglev", + "@v8//bazel/config:v8_target_arm64", + ], +) + +# WebAssembly remains enabled; its source implementation still depends on OS. +selects.config_setting_group( + name = "webassembly_on_posix", + match_any = ["@v8//bazel/config:is_posix"], +) + +selects.config_setting_group( + name = "webassembly_on_windows", + match_any = ["@v8//bazel/config:is_windows"], +) + +# Artifact transitions set True or False, never the upstream "none" auto mode. +# The 32-bit Torque helper can also force this off. +v8_string( + name = "v8_enable_pointer_compression", + default = "False", + visibility = ["//visibility:public"], +) + +config_setting( + name = "is_v8_enable_pointer_compression", + flag_values = {":v8_enable_pointer_compression": "True"}, +) + +# V8's pinned default short-call policy differs on x64 and arm64. In particular +# the legacy arm64 non-pointer-compressed profile still uses short calls. +selects.config_setting_group( + name = "v8_target_x64_default_short_builtin_calls", + match_all = [ + "@v8//bazel/config:v8_target_x64", + ":is_v8_enable_pointer_compression", + ], +) + +selects.config_setting_group( + name = "v8_target_arm64_default_short_builtin_calls", + match_all = [ + "@v8//bazel/config:v8_target_arm64", + "@v8//bazel/config:is_not_android", + ], +) + +selects.config_setting_group( + name = "is_v8_enable_short_builtin_calls", + match_any = [ + ":v8_target_x64_default_short_builtin_calls", + ":v8_target_arm64_default_short_builtin_calls", + ], +) + +# Fixed V8 15.2 Codex feature contract: TurboFan and WebAssembly enabled; +# experiments, VTune and profiling disabled. Do not hard-code ABI-sensitive +# values such as sandbox, pointer compression or target CPU into this baseline. +v8_config( + name = "define_flags", + conditional_defines = {"v8_enable_sandbox": "V8_ENABLE_SANDBOX"}, + defines = [ + "V8_DEPRECATION_WARNINGS", + "V8_IMMINENT_DEPRECATION_WARNINGS", + "V8_ENABLE_LAZY_SOURCE_POSITIONS", + "V8_ENABLE_TURBOFAN", + "V8_ENABLE_WEBASSEMBLY", + "V8_ENABLE_UNDEFINED_DOUBLE", + "V8_USE_HOST_CPU_ARM_FEATURES", + "V8_TYPED_ARRAY_MAX_SIZE_IN_HEAP=64", + ] + [ + "GOOGLE3", + "V8_ADVANCED_BIGINT_ALGORITHMS", + "V8_CONCURRENT_MARKING", + "V8_ENABLE_SPARKPLUG", + "V8_ENABLE_EXTENSIBLE_RO_SNAPSHOT", + "V8_ENABLE_CONTINUATION_PRESERVED_EMBEDDER_DATA", + "V8_TLS_USED_IN_LIBRARY", + ] + select({ + "@v8//bazel/config:is_debug": [ + "DEBUG", + "V8_ENABLE_CHECKS", + ], + "//conditions:default": [], + }) + select({ + "@v8//bazel/config:asan_enabled": ["V8_USE_ADDRESS_SANITIZER"], + "//conditions:default": [], + }) + select({ + "@v8//bazel/config:hwasan_enabled": ["V8_USE_HWADDRESS_SANITIZER"], + "//conditions:default": [], + }) + select({ + "@v8//bazel/config:msan_enabled": ["V8_USE_MEMORY_SANITIZER"], + "//conditions:default": [], + }) + select({ + "@v8//bazel/config:ubsan_enabled": ["V8_USE_UNDEFINED_BEHAVIOR_SANITIZER"], + "//conditions:default": [], + }) + select( + { + "@v8//bazel/config:v8_target_ia32": ["V8_TARGET_ARCH_IA32"], + "@v8//bazel/config:v8_target_x64": [ + "V8_TARGET_ARCH_X64", + "V8_ENABLE_SPARKPLUG_PLUS", + ], + "@v8//bazel/config:v8_target_arm": [ + "V8_TARGET_ARCH_ARM", + "CAN_USE_ARMV7_INSTRUCTIONS", + "CAN_USE_VFP3_INSTRUCTIONS", + ], + "@v8//bazel/config:v8_target_arm64": [ + "V8_TARGET_ARCH_ARM64", + "V8_ENABLE_SPARKPLUG_PLUS", + ], + "@v8//bazel/config:v8_target_s390x": [ + "V8_TARGET_ARCH_S390X", + ], + "@v8//bazel/config:v8_target_riscv64": [ + # NOTE: Bazel rules for riscv64 weren't tested on a real system. + "V8_TARGET_ARCH_RISCV64", + "CAN_USE_FPU_INSTRUCTIONS", + ], + "@v8//bazel/config:v8_target_ppc64le": [ + # NOTE: Bazel rules for ppc64le weren't tested on a real system. + "V8_TARGET_ARCH_PPC64", + ], + }, + no_match_error = "Please specify a target cpu supported by v8", + ) + select({ + "@v8//bazel/config:is_android": [ + "V8_HAVE_TARGET_OS", + "V8_TARGET_OS_ANDROID", + ], + "@v8//bazel/config:is_linux": [ + "V8_HAVE_TARGET_OS", + "V8_TARGET_OS_LINUX", + ], + "@v8//bazel/config:is_macos": [ + "V8_HAVE_TARGET_OS", + "V8_TARGET_OS_MACOS", + ], + "@v8//bazel/config:is_windows": [ + "V8_HAVE_TARGET_OS", + "V8_TARGET_OS_WIN", + "UNICODE", + "_UNICODE", + "_CRT_RAND_S", + "_WIN32_WINNT=0x0A00", + ], + }) + select({ + ":is_v8_enable_pointer_compression": [ + "V8_COMPRESS_POINTERS", + "V8_31BIT_SMIS_ON_64BIT_ARCH", + "V8_CONTIGUOUS_COMPRESSED_RO_SPACE", + "V8_CONTIGUOUS_COMPRESSED_RO_SPACE_SIZE_MB=8", + ], + "//conditions:default": [], + }) + select({ + ":is_v8_enable_pointer_compression": [ + "V8_COMPRESS_POINTERS_IN_SHARED_CAGE", + ], + "//conditions:default": [], + }) + select({ + ":is_v8_enable_short_builtin_calls": [ + "V8_SHORT_BUILTIN_CALLS", + ], + "//conditions:default": [], + }) + select({ + ":is_v8_enable_pointer_compression": [ + "V8_EXTERNAL_CODE_SPACE", + ], + "//conditions:default": [], + }) + select({ + ":is_v8_enable_pointer_compression": [ + "V8_STATIC_ROOTS", + ], + "//conditions:default": [], + }) + select({ + ":enable_maglev": [ + "V8_ENABLE_MAGLEV", + ], + "//conditions:default": [], + }) + [] + [], + deps = [], +) + +# Source inventory and dependency aliases. + +# The imported groups retain upstream select names. They express our pinned +# feature contract here; only CPU, ABI and the C++ runtime actually vary. +v8_fixed_source_features( + name = "pinned_source_features", + build_setting_default = True, +) + +config_setting( + name = "is_v8_enable_webassembly", + flag_values = {":pinned_source_features": "True"}, +) + +config_setting( + name = "disabled_pinned_source_feature", + flag_values = {":pinned_source_features": "False"}, +) + +alias( + name = "enable_drumbrake_arm64", + actual = ":disabled_pinned_source_feature", +) + +alias( + name = "enable_drumbrake_x64", + actual = ":disabled_pinned_source_feature", +) + +alias( + name = "enable_maglev_arm", + actual = ":disabled_pinned_source_feature", +) + +alias( + name = "enable_maglev_riscv", + actual = ":disabled_pinned_source_feature", +) + +alias( + name = "is_v8_enable_drumbrake", + actual = ":disabled_pinned_source_feature", +) + +alias( + name = "is_v8_enable_experimental_tq_to_tsa", + actual = ":disabled_pinned_source_feature", +) + +alias( + name = "is_v8_wasm_random_fuzzers", + actual = ":disabled_pinned_source_feature", +) + +# @CODEX_V8_UPSTREAM_FILEGROUPS@ + +alias( + name = "lib_dragonbox", + actual = "@v8_dragonbox//:dragonbox", +) + +alias( + name = "lib_fp16", + actual = "@v8_fp16//:fp16", +) + +# Generated files + +# Define file groups for torque definitions and initializers. Both are +# generated together, but we can use output_group to split them. +filegroup( + name = "icu/generated_torque_definitions", + srcs = [":icu/generated_torque_files"], + output_group = "definitions", +) + +filegroup( + name = "noicu/generated_torque_definitions", + srcs = [":noicu/generated_torque_files"], + output_group = "definitions", +) + +filegroup( + name = "icu/generated_torque_initializers", + srcs = [":icu/generated_torque_files"], + output_group = "initializers", +) + +filegroup( + name = "noicu/generated_torque_initializers", + srcs = [":noicu/generated_torque_files"], + output_group = "initializers", +) + +# TODO(victorgomes): Add support to tools/debug_helper, +# which needs class-debug-readers and debug-macros. +v8_torque_files( + name = "generated_torque_files", + args = [] + select({ + "@v8//bazel/config:v8_target_is_32_bits": ["-m32"], + "//conditions:default": [], + }), + definition_extras = [ + "bit-field-asserts.cc", + "builtin-definitions.h", + # "class-debug-readers.cc", + # "class-debug-readers.h", + "class-forward-declarations.h", + # "debug-macros.cc", + # "debug-macros.h", + "instance-types.h", + "interface-descriptors.inc", + ], + icu_srcs = [":icu/torque_files"], + initializer_extras = [ + "csa-types.h", + "enum-verifiers.cc", + "exported-macros-assembler.cc", + "exported-macros-assembler.h", + ], + noicu_srcs = [":noicu/torque_files"], +) + +py_binary( + name = "code_generator", + srcs = [ + "third_party/inspector_protocol/code_generator.py", + "third_party/inspector_protocol/pdl.py", + ], + data = [ + "third_party/inspector_protocol/lib/Forward_h.template", + "third_party/inspector_protocol/lib/Object_cpp.template", + "third_party/inspector_protocol/lib/Object_h.template", + "third_party/inspector_protocol/lib/Protocol_cpp.template", + "third_party/inspector_protocol/lib/ValueConversions_cpp.template", + "third_party/inspector_protocol/lib/ValueConversions_h.template", + "third_party/inspector_protocol/lib/Values_cpp.template", + "third_party/inspector_protocol/lib/Values_h.template", + "third_party/inspector_protocol/templates/Exported_h.template", + "third_party/inspector_protocol/templates/Imported_h.template", + "third_party/inspector_protocol/templates/TypeBuilder_cpp.template", + "third_party/inspector_protocol/templates/TypeBuilder_h.template", + ], + imports = ["third_party/inspector_protocol/"], + deps = [ + requirement("jinja2"), + ], +) + +genrule( + name = "generated_inspector_files", + srcs = [ + "include/js_protocol.pdl", + "src/inspector/inspector_protocol_config.json", + "third_party/inspector_protocol/code_generator.py", + "third_party/inspector_protocol/pdl.py", + "third_party/inspector_protocol/lib/Forward_h.template", + "third_party/inspector_protocol/lib/Object_cpp.template", + "third_party/inspector_protocol/lib/Object_h.template", + "third_party/inspector_protocol/lib/Protocol_cpp.template", + "third_party/inspector_protocol/lib/ValueConversions_cpp.template", + "third_party/inspector_protocol/lib/ValueConversions_h.template", + "third_party/inspector_protocol/lib/Values_cpp.template", + "third_party/inspector_protocol/lib/Values_h.template", + "third_party/inspector_protocol/templates/Exported_h.template", + "third_party/inspector_protocol/templates/Imported_h.template", + "third_party/inspector_protocol/templates/TypeBuilder_cpp.template", + "third_party/inspector_protocol/templates/TypeBuilder_h.template", + ], + outs = [ + "include/inspector/Debugger.h", + "include/inspector/Runtime.h", + "include/inspector/Schema.h", + "src/inspector/protocol/Forward.h", + "src/inspector/protocol/Protocol.cpp", + "src/inspector/protocol/Protocol.h", + "src/inspector/protocol/Console.cpp", + "src/inspector/protocol/Console.h", + "src/inspector/protocol/Debugger.cpp", + "src/inspector/protocol/Debugger.h", + "src/inspector/protocol/HeapProfiler.cpp", + "src/inspector/protocol/HeapProfiler.h", + "src/inspector/protocol/Profiler.cpp", + "src/inspector/protocol/Profiler.h", + "src/inspector/protocol/Runtime.cpp", + "src/inspector/protocol/Runtime.h", + "src/inspector/protocol/Schema.cpp", + "src/inspector/protocol/Schema.h", + ], + cmd = "INSPECTOR_PROTOCOL_DIR=$$(dirname $(execpath third_party/inspector_protocol/code_generator.py)); \ + PYTHONPATH=$$INSPECTOR_PROTOCOL_DIR:external/rules_python++pip+v8_python_deps_311_jinja2/site-packages:external/rules_python++pip+v8_python_deps_311_markupsafe/site-packages:$${PYTHONPATH-} \ + $(execpath @rules_python//python/bin:python) $(execpath third_party/inspector_protocol/code_generator.py) --jinja_dir . \ + --inspector_protocol_dir $$INSPECTOR_PROTOCOL_DIR \ + --config $(location :src/inspector/inspector_protocol_config.json) \ + --config_value protocol.path=$(location :include/js_protocol.pdl) \ + --config_value crdtp.dir=third_party/inspector_protocol/crdtp \ + --output_base $(@D)/src/inspector", + message = "Generating inspector files", + tools = [ + "@rules_python//python/bin:python", + requirement("jinja2"), + requirement("markupsafe"), + ], +) + +filegroup( + name = "v8_common_libshared_files", + srcs = [ + ":torque_runtime_support_files", + ":v8_libplatform_files", + ":v8_libsampler_files", + ":v8_shared_internal_headers", + ], +) + +cc_library( + name = "rusty_v8_internal_headers", + hdrs = [ + "src/libplatform/default-platform.h", + ], + strip_include_prefix = "", + visibility = ["//visibility:public"], +) + +cc_library( + name = "rusty_v8_crdtp_headers", + hdrs = [ + "third_party/inspector_protocol/crdtp/cbor.h", + "third_party/inspector_protocol/crdtp/dispatch.h", + "third_party/inspector_protocol/crdtp/error_support.h", + "third_party/inspector_protocol/crdtp/export.h", + "third_party/inspector_protocol/crdtp/find_by_first.h", + "third_party/inspector_protocol/crdtp/frontend_channel.h", + "third_party/inspector_protocol/crdtp/json.h", + "third_party/inspector_protocol/crdtp/parser_handler.h", + "third_party/inspector_protocol/crdtp/protocol_core.h", + "third_party/inspector_protocol/crdtp/serializable.h", + "third_party/inspector_protocol/crdtp/span.h", + "third_party/inspector_protocol/crdtp/status.h", + ], + strip_include_prefix = "", + visibility = ["//visibility:public"], +) + +filegroup( + name = "d8_files", + srcs = [ + "src/d8/async-hooks-wrapper.cc", + "src/d8/async-hooks-wrapper.h", + "src/d8/d8.cc", + "src/d8/d8.h", + "src/d8/d8-console.cc", + "src/d8/d8-console.h", + "src/d8/d8-js.cc", + "src/d8/d8-platforms.cc", + "src/d8/d8-platforms.h", + "src/d8/d8-posix.cc", + "src/d8/d8-test.cc", + "src/d8/hardware-watchpoints.cc", + "src/d8/hardware-watchpoints.h", + "src/d8/memory-access-information.cc", + "src/d8/memory-access-information.h", + ], +) + +genrule( + name = "generated_bytecode_builtins_list", + srcs = [], + outs = ["builtins-generated/bytecodes-builtins-list.h"], + cmd = "$(location :bytecode_builtins_list_generator) $@", + cmd_bat = "$(location :bytecode_builtins_list_generator) $@", + tools = [":bytecode_builtins_list_generator"], +) + +genrule( + name = "generated_regexp_special_case", + srcs = [], + outs = ["src/regexp/special-case.cc"], + cmd = "$(location :regexp_special_case_generator) $@", + cmd_bat = "$(location :regexp_special_case_generator) $@", + tools = [":regexp_special_case_generator"], +) + +v8_mksnapshot( + name = "generated_snapshot_files", + args = [] + [] + [] + ["--no-native-code-counters"], +) + +# ================================================= +# Libraries rules +# ================================================= + +# NOTE: This allow headers to be accessed without the icu/noicu prefixes. +cc_library( + name = "icu/generated_torque_definitions_headers", + hdrs = [":icu/generated_torque_definitions"], + copts = ["-Wno-implicit-fallthrough"], + strip_include_prefix = "icu", +) + +cc_library( + name = "noicu/generated_torque_definitions_headers", + hdrs = [":noicu/generated_torque_definitions"], + copts = ["-Wno-implicit-fallthrough"], + strip_include_prefix = "noicu", +) + +alias( + name = "llvm_libc_headers", + actual = "@@//third_party/v8:rusty_v8_llvm_libc_headers", +) + +v8_library( + name = "v8_libbase", + srcs = [ + ":v8_libbase_files", + ":v8_shared_internal_headers", + ], + copts = ["-Wno-implicit-fallthrough"], + deps = [ + ":llvm_libc_headers", + "@v8_abseil//:overload", + "@v8_abseil//:synchronization", + "@v8_abseil//:time", + ], +) + +cc_library( + name = "torque_base_headers", + hdrs = [ + "src/torque/kythe-data.h", + "src/torque/torque-compiler.h", + ], + copts = ["-Wno-implicit-fallthrough"], + include_prefix = "third_party/v8", + includes = ["."], +) + +cc_library( + name = "kythe_torque_base", + srcs = [ + ":torque_base_files", + ], + copts = ["-Wno-implicit-fallthrough"] + select({ + "@v8//bazel/config:is_posix": ["-fexceptions"], + "//conditions:default": [], + }), + features = ["-use_header_modules"], + deps = [ + ":torque_base_headers", + ":v8_libbase", + "@v8_abseil//:node_hash_map", + "@v8_abseil//:node_hash_set", + ], +) + +alias( + name = "simdutf", + actual = "@v8_simdutf//:simdutf", +) + +v8_library( + name = "v8_libshared", + srcs = [ + ":v8_base_without_compiler_files", + ":v8_common_libshared_files", + ] + [ + ":v8_compiler_files", + ], + copts = ["-Wno-implicit-fallthrough"], + icu_deps = [ + ":icu/generated_torque_definitions_headers", + "@v8_icu//:icu", + ], + icu_srcs = [ + ":generated_regexp_special_case", + ":icu/generated_torque_definitions", + ":icu/v8_base_without_compiler_files", + ], + noicu_deps = [ + ":noicu/generated_torque_definitions_headers", + ], + noicu_srcs = [ + ":noicu/generated_torque_definitions", + ], + deps = [ + # keep + ":lib_dragonbox", + "@v8_fast_float//:fast_float", + ":lib_fp16", + ":simdutf", + ":v8_libbase", + "@v8_abseil//:btree", + "@v8_abseil//:flat_hash_map", + "@v8_abseil//:flat_hash_set", + "@v8_abseil//:overload", + "@v8_highway//:hwy", + ], +) + +v8_library( + name = "v8", + srcs = [ + ":v8_inspector_files", + ] + [], + hdrs = [":public_header_files"], + copts = ["-Wno-implicit-fallthrough"], + icu_deps = [":icu/v8_libshared"], + icu_srcs = [":icu/snapshot_files"], + noicu_deps = [":noicu/v8_libshared"], + noicu_srcs = [":noicu/snapshot_files"], + visibility = ["//visibility:public"], +) + +v8_library( + name = "wee8", + srcs = [":wee8_files"], + hdrs = [":public_wasm_c_api_header_files"], + copts = ["-Wno-implicit-fallthrough"], + strip_include_prefix = "third_party", + visibility = ["//visibility:public"], + deps = [":noicu/v8"] + [], +) + +alias( + name = "core_lib_noicu", + actual = "noicu/v8", +) + +alias( + name = "core_lib_icu", + actual = "icu/v8", + visibility = ["//visibility:public"], +) + +v8_library( + name = "v8_vtune", + srcs = [ + "third_party/vtune/v8-vtune.h", + "third_party/vtune/vtune-jit.cc", + "third_party/vtune/vtune-jit.h", + ], + copts = ["-I"], + deps = [ + ":core_lib_noicu", + "@intel_ittapi//:lib_ittapi", + ], +) + +# ================================================= +# Binary rules +# ================================================= + +v8_binary( + name = "bytecode_builtins_list_generator", + srcs = [ + "src/builtins/generate-bytecodes-builtins-list.cc", + "src/interpreter/bytecode-operands.cc", + "src/interpreter/bytecode-operands.h", + "src/interpreter/bytecode-traits.h", + "src/interpreter/bytecodes.cc", + "src/interpreter/bytecodes.h", + ], + copts = ["-Wno-implicit-fallthrough"], + deps = ["v8_libbase"], +) + +v8_binary( + name = "regexp_special_case_generator", + srcs = [ + "src/regexp/gen-regexp-special-case.cc", + "src/regexp/special-case.h", + ":v8_shared_internal_headers", + ], + copts = ["-Wno-implicit-fallthrough"], + defines = [ + "V8_INTL_SUPPORT", + "ICU_UTIL_DATA_IMPL=ICU_UTIL_DATA_STATIC", + # src/regexp/regexp-compiler-tonode.cc uses an unsafe ICU method and + # access a character implicitly. + "UNISTR_FROM_CHAR_EXPLICIT=", + ], + deps = [ + ":v8_libbase", + "@v8_icu//:icu", + ], +) + +v8_binary( + name = "torque", + srcs = [ + "src/torque/torque.cc", + ":torque_base_files", + ], + copts = ["-Wno-implicit-fallthrough"] + select({ + "@v8//bazel/config:is_posix": ["-fexceptions"], + "//conditions:default": [], + }), + features = ["-use_header_modules"], + icu_defines = ["V8_INTL_SUPPORT"], + linkopts = select({ + "@v8//bazel/config:is_android": ["-llog"], + "//conditions:default": [], + }), + deps = [ + "v8_libbase", + "@v8_abseil//:node_hash_map", + "@v8_abseil//:node_hash_set", + ], +) + +v8_binary( + name = "mksnapshot", + srcs = [ + ":mksnapshot_files", + ] + [], + copts = ["-Wno-implicit-fallthrough"], + icu_deps = [":icu/v8_libshared"], + icu_srcs = [ + ":icu/generated_torque_initializers", + ":icu/v8_initializers_files", + ], + # Match GN's mksnapshot `disable_icf` config. If the linker folds distinct + # external-reference helpers together while producing the snapshot, the + # final embedder binary may not fold the same pair and startup + # deserialization will reject the snapshot. + # GN also increases the x64 Windows stack reserve because constructing the + # snapshot overflows the linker's 1 MiB default. + linkopts = select({ + "@v8//bazel/config:is_android": ["-llog"], + "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], + "@v8//bazel/config:is_windows": [ + "-Wl,/OPT:NOICF", + "-Wl,/STACK:2097152", + ], + "//conditions:default": ["-Wl,--icf=none"], + }), + noicu_deps = [":v8_libshared_noicu"], + noicu_srcs = [ + ":noicu/generated_torque_initializers", + ":noicu/v8_initializers_files", + ], +) + +v8_binary( + name = "d8", + srcs = [":d8_files"], + copts = ["-Wno-implicit-fallthrough"], + icu_deps = [":icu/v8"], + noicu_deps = [":noicu/v8"], +) + +# This target forces torque to be compiled without pointer compression. +v8_binary_non_pointer_compression( + name = "noicu/torque_non_pointer_compression", + binary = "noicu/torque", +) + +v8_binary_non_pointer_compression( + name = "icu/torque_non_pointer_compression", + binary = "icu/torque", +) + +alias( + name = "v8ci", + actual = "noicu/v8", +) + +# ================================================= +# Tests +# ================================================= + +v8_build_config( + name = "v8_build_config", + arch = select({ + "@v8//bazel/config:v8_target_arm64": "arm64", + "@v8//bazel/config:v8_target_x64": "x64", + "//conditions:default": "x64", + }), +) + +# Runs mjsunit with d8. +py_test( + name = "noicu/mjsunit", + size = "medium", + srcs = [ + "test/mjsunit/testcfg.py", + "tools/predictable_wrapper.py", + "tools/run-tests.py", + ] + glob(["tools/testrunner/**/*.py"]), + args = [ + "--no-sorting", + "--nopresubmit", + "--variant=google3", + "--outdir noicu", + "--verbose", + ] + select({ + "@v8//bazel/config:v8_target_arm64": [ + "--arch=arm64", + ], + "@v8//bazel/config:v8_target_x64": [ + "--arch=x64", + ], + "//conditions:default": [], + }) + [ + "mjsunit", + ], + data = [ + "test", + ":noicu/d8", + ":noicu/v8_build_config", + "//testing/pybase", + ] + glob(["test/**"]) + glob(["tools/**/*.js"]) + glob(["tools/**/*.mjs"]), + main = "tools/run-tests.py", + tags = [ + # Disable sanitizers, as they don't work in general in V8. + "nosan", + ], +) + +py_test( + name = "icu/mjsunit", + size = "medium", + srcs = [ + "test/mjsunit/testcfg.py", + "tools/predictable_wrapper.py", + "tools/run-tests.py", + ] + glob(["tools/testrunner/**/*.py"]), + args = [ + "--no-sorting", + "--nopresubmit", + "--variant=google3", + "--outdir icu", + "--verbose", + ] + select({ + "@v8//bazel/config:v8_target_arm64": [ + "--arch=arm64", + ], + "@v8//bazel/config:v8_target_x64": [ + "--arch=x64", + ], + "//conditions:default": [], + }) + [ + "mjsunit", + ], + data = [ + "test", + ":icu/d8", + ":icu/v8_build_config", + "//testing/pybase", + ] + glob(["test/**"]) + glob(["tools/**/*.js"]) + glob(["tools/**/*.mjs"]), + main = "tools/run-tests.py", + tags = [ + # Disable sanitizers, as they don't work in general in V8. + "nosan", + ], +) + +py_test( + name = "icu/intl", + size = "medium", + srcs = [ + "test/intl/testcfg.py", + "tools/predictable_wrapper.py", + "tools/run-tests.py", + ] + glob(["tools/testrunner/**/*.py"]), + args = [ + "--no-sorting", + "--nopresubmit", + "--variant=google3", + "--outdir icu", + "--verbose", + ] + select({ + "@v8//bazel/config:v8_target_arm64": [ + "--arch=arm64", + ], + "@v8//bazel/config:v8_target_x64": [ + "--arch=x64", + ], + "//conditions:default": [], + }) + [ + "intl", + ], + data = [ + "test", + ":icu/d8", + ":icu/v8_build_config", + "//testing/pybase", + ] + glob(["test/**"]) + glob(["tools/**/*.js"]) + glob(["tools/**/*.mjs"]), + main = "tools/run-tests.py", + tags = [ + # Disable sanitizers, as they don't work in general in V8. + "nosan", + ], +) diff --git a/third_party/v8/engine_repository.bzl b/third_party/v8/engine_repository.bzl new file mode 100644 index 000000000000..dd6b966623bd --- /dev/null +++ b/third_party/v8/engine_repository.bzl @@ -0,0 +1,93 @@ +"""V8 source import with Codex-owned Bazel entry points. + +The root package is placed inside the fetched source tree so relative source +labels and globs have their normal Bazel semantics. Reuse upstream's platform +and CPU configuration in place, with Codex's narrow patch to bazel/defs.bzl. +V8's MODULE and toolchain BUILD files are never part of Codex's dependency or +toolchain graph. +""" + +# Upstream keeps its source groups in BUILD.bazel, which cannot be loaded as +# a Starlark library. Reuse filegroups, so labels/globs retain their semantics +# inside @v8. Compiled targets (including valid V8 v8_library/v8_binary calls) +# belong in engine.BUILD.bazel, where we wire Codex's dependencies and runtime. +_INVENTORY_SLOT = "# @CODEX_V8_UPSTREAM_FILEGROUPS@" +_FILE_GROUPS = "# File group rules" +_GENERATED_FILES = "# Generated files" + +def _upstream_filegroups(upstream): + before_and_groups = upstream.split(_FILE_GROUPS) + if len(before_and_groups) != 2: + fail("V8 source import: expected one File group rules section; review the new pin's BUILD.bazel") + groups_and_after = before_and_groups[1].split(_GENERATED_FILES) + if len(groups_and_after) != 2: + fail("V8 source import: expected one Generated files boundary; review the new pin's BUILD.bazel") + + groups = [] + statement = [] + for line in groups_and_after[0].split("\n"): + if statement: + statement.append(line) + if line == ")": + source = "\n".join(statement) + if statement[0] == "filegroup(": + groups.append(source) + elif 'name = "lib_dragonbox",' not in source and 'name = "lib_fp16",' not in source: + fail("V8 filegroup import: unexpected library; review and wire it in third_party/v8/engine.BUILD.bazel: " + source) + statement = [] + elif line in ["filegroup(", "v8_library("]: + # The pinned section happens to contain two libraries. Both are + # already provided by our dependency overlays; skip only those names. + statement = [line] + elif line.strip() and not line.startswith("#"): + fail("V8 filegroup import: only filegroups can be reused here; review compiled targets (including v8_binary) in third_party/v8/engine.BUILD.bazel. Unexpected statement: " + line) + + # Group count and library order can change without changing the owned graph. + # Fail only when the source section is empty, malformed, or unsafe to reuse. + if statement: + fail("V8 filegroup import: unterminated statement; review the pinned BUILD.bazel: " + "\n".join(statement)) + if not groups: + fail("V8 filegroup import: no filegroups found; review the pinned BUILD.bazel") + return "\n\n".join(groups) + +def _engine_repository_impl(ctx): + ctx.download_and_extract( + url = ctx.attr.urls, + integrity = ctx.attr.integrity, + stripPrefix = ctx.attr.strip_prefix, + ) + inventory = _upstream_filegroups(ctx.read("BUILD.bazel")) + template = ctx.read(ctx.attr._engine_build) + if template.count(_INVENTORY_SLOT) != 1: + fail("Codex V8 BUILD template must contain exactly one upstream filegroup slot") + + for patch in ctx.attr.patches: + ctx.patch(patch, strip = ctx.attr.patch_strip) + + # Preserve V8's source layout and the two third-party package boundaries + # (disarm and fadec); neither is in the Codex V8 closure. Keep upstream's + # platform/CPU package and prevent upstream toolchain discovery. + for path in [ + "MODULE.bazel", + "BUILD.bazel", + "bazel/toolchain/BUILD.bazel", + "bazel/patches/BUILD.bazel", + ]: + ctx.delete(path) + ctx.file("BUILD.bazel", template.replace(_INVENTORY_SLOT, inventory)) + +engine_repository = repository_rule( + implementation = _engine_repository_impl, + attrs = { + "urls": attr.string_list(mandatory = True), + "integrity": attr.string(mandatory = True), + "strip_prefix": attr.string(mandatory = True), + "patches": attr.label_list(allow_files = True), + "patch_strip": attr.int(default = 3), + "_engine_build": attr.label( + default = Label("//third_party/v8:engine.BUILD.bazel"), + allow_single_file = True, + ), + }, + doc = "Fetch the pinned V8 source tree and install Codex's engine graph.", +) diff --git a/third_party/v8/engine_rules.bzl b/third_party/v8/engine_rules.bzl new file mode 100644 index 000000000000..87261f33aa92 --- /dev/null +++ b/third_party/v8/engine_rules.bzl @@ -0,0 +1,13 @@ +"""Codex's fixed selection for branches of the imported V8 source inventory.""" + +load("@v8//:bazel/defs.bzl", "FlagInfo") + +def _options_impl(ctx): + return FlagInfo(value = ctx.build_setting_value) + +# Source-only features keep upstream select names without adding CLI options. +# ABI/runtime settings are still V8 release inputs until artifact profiles take over. +v8_fixed_source_features = rule( + implementation = _options_impl, + build_setting = config.bool(flag = False), +) diff --git a/third_party/v8/v8_defs.patch b/third_party/v8/v8_defs.patch new file mode 100644 index 000000000000..fea274539dd0 --- /dev/null +++ b/third_party/v8/v8_defs.patch @@ -0,0 +1,176 @@ +# What: adapt the pinned V8 rule helpers to Codex's engine configuration. +# Scope: bazel/defs.bzl only; the engine graph and dependency choices live in +# third_party/v8/engine.BUILD.bazel. The platform/CPU config is unmodified V8. +# Preserve the existing release flags, Chromium libc++ headers/runtime, Clang +# Windows driver flags, and Windows target-ABI snapshot tool. The optional +# Linux backtrace link flag is omitted because that feature is fixed off. +# On a V8 pin change, review these hunks and build the native canary matrix. + +--- a/orig/v8-15.2.124.1/bazel/defs.bzl ++++ b/mod/v8-15.2.124.1/bazel/defs.bzl +@@ -42,12 +42,30 @@ + ) + + def v8_flag(name, default = False): +- _create_option_flag(name = name, build_setting_default = default, scope = "universal") +- native.config_setting(name = "is_" + name, flag_values = {name: "True"}) +- native.config_setting(name = "is_not_" + name, flag_values = {name: "False"}) +- +-def v8_string(name, default = ""): +- _create_option_string(name = name, build_setting_default = default, scope = "universal") ++ _create_option_flag( ++ name = name, ++ build_setting_default = default, ++ scope = "universal", ++ visibility = ["//visibility:public"], ++ ) ++ native.config_setting( ++ name = "is_" + name, ++ flag_values = {name: "True"}, ++ visibility = ["//visibility:public"], ++ ) ++ native.config_setting( ++ name = "is_not_" + name, ++ flag_values = {name: "False"}, ++ visibility = ["//visibility:public"], ++ ) ++ ++def v8_string(name, default = "", visibility = None): ++ _create_option_string( ++ name = name, ++ build_setting_default = default, ++ scope = "universal", ++ visibility = visibility, ++ ) + + def v8_int(name, default = 0): + _create_option_int(name = name, build_setting_default = default, scope = "universal") +@@ -106,7 +124,13 @@ + + def _default_args(): + return struct( +- deps = [":define_flags", "@libcxx//:libc++"], ++ deps = [":define_flags"] + select({ ++ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ ++ "@@//third_party/v8:rusty_v8_custom_libcxx_headers", ++ "@@//third_party/v8:rusty_v8_custom_libcxx_runtime", ++ ], ++ "//conditions:default": [], ++ }), + defines = select({ + "@v8//bazel/config:is_windows": [ + "UNICODE", +@@ -136,12 +160,15 @@ + ], + "//conditions:default": [], + }) + select({ +- "@v8//bazel/config:is_clang": [ +- "-Wno-invalid-offsetof", +- "-Wno-deprecated-this-capture", +- "-Wno-deprecated-declarations", +- "-std=c++20", +- ], ++ "@v8//:is_v8_use_rusty_v8_custom_libcxx": [ ++ "-nostdinc++", ++ "-D_LIBCPP_DISABLE_VISIBILITY_ANNOTATIONS", ++ "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE", ++ "-D_LIBCPP_INSTRUMENTED_WITH_ASAN=0", ++ "-D_LIBCXXABI_DISABLE_VISIBILITY_ANNOTATIONS", ++ ], ++ "//conditions:default": [], ++ }) + select({ + "@v8//bazel/config:is_gcc": [ + "-Wno-extra", + "-Wno-array-bounds", +@@ -161,9 +188,17 @@ + "-std=gnu++2a", + ], + "@v8//bazel/config:is_windows": [ +- "/std:c++20", +- ], +- "//conditions:default": [], ++ "-Wno-invalid-offsetof", ++ "-Wno-deprecated-this-capture", ++ "-Wno-deprecated-declarations", ++ "-std=c++20", ++ ], ++ "//conditions:default": [ ++ "-Wno-invalid-offsetof", ++ "-Wno-deprecated-this-capture", ++ "-Wno-deprecated-declarations", ++ "-std=c++20", ++ ], + }) + select({ + "@v8//bazel/config:is_gcc_fastbuild": [ + # Non-debug builds without optimizations fail because +@@ -187,15 +222,12 @@ + includes = ["include"], + linkopts = select({ + "@v8//bazel/config:is_windows": [ +- "Winmm.lib", +- "DbgHelp.lib", +- "Advapi32.lib", ++ "-lwinmm", ++ "-ldbghelp", ++ "-ladvapi32", + ], + "@v8//bazel/config:is_macos": ["-pthread"], +- "//conditions:default": ["-Wl,--no-as-needed -ldl -latomic -pthread"], +- }) + select({ +- ":should_add_rdynamic": ["-rdynamic"], +- "//conditions:default": [], ++ "//conditions:default": ["-Wl,--no-as-needed -ldl -pthread"], + }), + ) + +@@ -466,6 +498,12 @@ + def _mksnapshot(ctx): + prefix = ctx.attr.prefix + suffix = ctx.attr.suffix ++ ++ # Windows cross-builds use Linux for the exec configuration, but the ++ # snapshot generator must match the target ABI and run on the Windows ++ # runner. Action strategies only choose where an action runs; they cannot ++ # change this executable from the exec to the target configuration. ++ tool = ctx.executable.target_tool if ctx.attr.target_os == "win" else ctx.executable.tool + outs = [ + ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), + ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), +@@ -483,7 +521,7 @@ + "--embedded_src", + outs[1].path, + ] + ctx.attr.args, +- executable = ctx.executable.tool, ++ executable = tool, + progress_message = "Running mksnapshot", + ) + return [DefaultInfo(files = depset(outs))] +@@ -498,6 +536,12 @@ + executable = True, + cfg = "exec", + ), ++ "target_tool": attr.label( ++ mandatory = True, ++ allow_files = True, ++ executable = True, ++ cfg = "target", ++ ), + "target_os": attr.string(mandatory = True), + "prefix": attr.string(mandatory = True), + "suffix": attr.string(mandatory = True), +@@ -511,6 +555,7 @@ + args = args, + prefix = "noicu", + tool = ":noicu/mksnapshot" + suffix, ++ target_tool = ":noicu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", +@@ -523,6 +568,7 @@ + args = args, + prefix = "icu", + tool = ":icu/mksnapshot" + suffix, ++ target_tool = ":icu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", From 108cf2975a99d3a5df5006c29d9d105ee85364c7 Mon Sep 17 00:00:00 2001 From: Sean Huang Date: Fri, 23 Oct 2026 18:38:49 +0000 Subject: [PATCH 002/287] Enforce V8 artifact profiles at Bazel target boundaries (#57165) ## Why V8 source and release artifacts require different ABI and C++ runtime settings. Selecting a named artifact should also select its required settings, without callers coordinating separate sandbox, pointer compression, libc++, and CPU flags. ## What changed - Add Bazel transitions for `source`, `release`, and `sandbox_release` profiles. Source builds use the sandbox ABI and workspace runtime; release pairs use Chromium's pinned runtime with their respective ABI. - Select the V8 CPU from the artifact target and enforce matching target platform constraints. - Make the underlying V8 settings unavailable as CLI flags and remove redundant configuration from release tooling and workflows. - Select Darwin/Linux prebuilts solely through `rusty_v8_from_source`, keeping source archives and bindings paired when enabled. GitOrigin-RevId: f72fc06b6f74b79ab2ab8ca697e4f2006076a833 --- .bazelrc | 17 ---- .github/scripts/rusty_v8_bazel.py | 10 -- .github/scripts/test_rusty_v8_bazel.py | 24 +---- .github/workflows/rusty-v8-release.yml | 22 ---- .github/workflows/v8-canary.yml | 22 ---- third_party/v8/BUILD.bazel | 136 +++++++++++++++++-------- third_party/v8/README.md | 34 ++++--- third_party/v8/artifact_profile.bzl | 76 ++++++++++++++ third_party/v8/engine.BUILD.bazel | 4 +- third_party/v8/engine_repository.bzl | 3 +- third_party/v8/engine_rules.bzl | 2 +- third_party/v8/v8_defs.patch | 43 +++++++- 12 files changed, 238 insertions(+), 155 deletions(-) create mode 100644 third_party/v8/artifact_profile.bzl diff --git a/.bazelrc b/.bazelrc index 4e2195c6b717..fafa36952a44 100644 --- a/.bazelrc +++ b/.bazelrc @@ -216,23 +216,6 @@ common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 common:ci-v8 --build_metadata=TAG_os=linux -# Source-built Bazel V8 artifacts use the in-process sandbox by default. This -# does not affect Cargo's default prebuilt rusty_v8 path. -common --@v8//:v8_enable_pointer_compression=True -common --@v8//:v8_enable_sandbox=True - -# Keep currently published rusty_v8 release artifacts non-sandboxed until the -# artifact migration ships matching Rust feature selection for Cargo consumers. -common:v8-release-compat --@v8//:v8_enable_pointer_compression=False -common:v8-release-compat --@v8//:v8_enable_sandbox=False -common:v8-target-x64 --@v8//bazel/config:v8_target_cpu=x64 -common:v8-target-arm64 --@v8//bazel/config:v8_target_cpu=arm64 - -# Match rusty_v8's upstream GN release contract for published artifacts: every -# target object uses Chromium's custom libc++ headers and the archive folds in -# the matching runtime objects. -common:rusty-v8-upstream-libcxx --@v8//:v8_use_rusty_v8_custom_libcxx=True - # Native Windows MSVC builds inherit the runner's MSVC SDK environment. # Client environment capture and repository discovery happen before target # transitions, so these runner settings cannot live in runtime_package.bzl. diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 6150aff5af12..af45ea22b024 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -26,7 +26,6 @@ RUSTY_V8_CHECKSUMS_DIR = ROOT / "third_party" / "v8" RELEASE_ARTIFACT_PROFILE = "release" SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" -ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] def bazel_execroot() -> Path: @@ -125,14 +124,6 @@ def ensure_bazel_output_files( return outputs -def artifact_bazel_configs(bazel_configs: list[str] | None = None) -> list[str]: - configured = list(ARTIFACT_BAZEL_CONFIGS) - for config in bazel_configs or []: - if config not in configured: - configured.append(config) - return configured - - def release_pair_label(target: str, sandbox: bool = False) -> str: target_suffix = target.replace("-", "_") pair_kind = "sandbox_release_pair" if sandbox else "release_pair" @@ -290,7 +281,6 @@ def stage_release_pair( bazel_configs: list[str] | None = None, sandbox: bool = False, ) -> None: - bazel_configs = artifact_bazel_configs(bazel_configs) outputs = ensure_bazel_output_files( platform, [release_pair_label(target, sandbox)], diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index e0ed40b00e88..290d89f8b37b 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -25,7 +25,7 @@ def test_consumer_selectors_track_resolved_crate_version(self) -> None: "aarch64_pc_windows_gnullvm", "aarch64_pc_windows_msvc", "aarch64_unknown_linux_gnu_bazel", - "aarch64_unknown_linux_musl_release_base", + "aarch64_unknown_linux_musl_release", "x86_64_apple_darwin_bazel", "x86_64_pc_windows_gnullvm", "x86_64_pc_windows_msvc", @@ -72,22 +72,6 @@ def test_command_version_tracks_remaining_http_file_assets(self) -> None: with patch.object(rusty_v8_bazel, "MODULE_BAZEL", module_bazel): self.assertEqual("146.4.0", rusty_v8_bazel.command_version(None)) - def test_artifact_bazel_configs_always_enable_upstream_libcxx(self) -> None: - self.assertEqual( - ["rusty-v8-upstream-libcxx"], - rusty_v8_bazel.artifact_bazel_configs(), - ) - self.assertEqual( - ["rusty-v8-upstream-libcxx", "v8-release-compat"], - rusty_v8_bazel.artifact_bazel_configs(["v8-release-compat"]), - ) - self.assertEqual( - ["rusty-v8-upstream-libcxx", "v8-release-compat"], - rusty_v8_bazel.artifact_bazel_configs( - ["rusty-v8-upstream-libcxx", "v8-release-compat"] - ), - ) - def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None: with patch.dict(environ, {}, clear=True): self.assertEqual( @@ -276,7 +260,7 @@ def test_ensure_bazel_output_files_rebuilds_existing_outputs(self) -> None: "macos_arm64", ["//third_party/v8:pair"], "opt", - ["rusty-v8-upstream-libcxx"], + ["ci-macos"], ), ) @@ -284,14 +268,14 @@ def test_ensure_bazel_output_files_rebuilds_existing_outputs(self) -> None: "macos_arm64", ["//third_party/v8:pair"], "opt", - ["rusty-v8-upstream-libcxx"], + ["ci-macos"], download_toplevel=True, ) bazel_output_files.assert_called_once_with( "macos_arm64", ["//third_party/v8:pair"], "opt", - ["rusty-v8-upstream-libcxx"], + ["ci-macos"], ) def test_update_module_bazel_replaces_and_inserts_sha256(self) -> None: diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 3dcdbe09859d..13489a779057 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -76,84 +76,72 @@ jobs: platform: linux_amd64 sandbox: false target: x86_64-unknown-linux-gnu - v8_cpu: x64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64 sandbox: true target: x86_64-unknown-linux-gnu - v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: false target: aarch64-unknown-linux-gnu - v8_cpu: arm64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: true target: aarch64-unknown-linux-gnu - v8_cpu: arm64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: false target: x86_64-apple-darwin - v8_cpu: x64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: true target: x86_64-apple-darwin - v8_cpu: x64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: false target: aarch64-apple-darwin - v8_cpu: arm64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: true target: aarch64-apple-darwin - v8_cpu: arm64 variant: ptrcomp-sandbox - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: false target: x86_64-unknown-linux-musl - v8_cpu: x64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: false target: aarch64-unknown-linux-musl - v8_cpu: arm64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: true target: x86_64-unknown-linux-musl - v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: true target: aarch64-unknown-linux-musl - v8_cpu: arm64 variant: ptrcomp-sandbox steps: @@ -183,7 +171,6 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} - V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -200,14 +187,9 @@ jobs: -c opt "--platforms=@llvm//platforms:${PLATFORM}" - --config=rusty-v8-upstream-libcxx - "--config=v8-target-${V8_CPU}" "${pair_target}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) - if [[ "${SANDBOX}" != "true" ]]; then - bazel_args+=(--config=v8-release-compat) - fi ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ @@ -221,7 +203,6 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} - V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -232,12 +213,9 @@ jobs: --compilation-mode opt --output-dir "dist/${TARGET}" --bazel-config "${BAZEL_CONFIG}" - --bazel-config "v8-target-${V8_CPU}" ) if [[ "${SANDBOX}" == "true" ]]; then stage_args+=(--sandbox) - else - stage_args+=(--bazel-config v8-release-compat) fi python3 .github/scripts/rusty_v8_bazel.py stage-release-pair "${stage_args[@]}" diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index ae155133ed9c..77767cb3e4b6 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -90,84 +90,72 @@ jobs: platform: linux_amd64 sandbox: false target: x86_64-unknown-linux-gnu - v8_cpu: x64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64 sandbox: true target: x86_64-unknown-linux-gnu - v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: false target: aarch64-unknown-linux-gnu - v8_cpu: arm64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64 sandbox: true target: aarch64-unknown-linux-gnu - v8_cpu: arm64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: false target: x86_64-apple-darwin - v8_cpu: x64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_amd64 sandbox: true target: x86_64-apple-darwin - v8_cpu: x64 variant: ptrcomp-sandbox - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: false target: aarch64-apple-darwin - v8_cpu: arm64 variant: release - runner: macos-15-xlarge bazel_config: ci-macos platform: macos_arm64 sandbox: true target: aarch64-apple-darwin - v8_cpu: arm64 variant: ptrcomp-sandbox - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: false target: x86_64-unknown-linux-musl - v8_cpu: x64 variant: release - runner: ubuntu-24.04 bazel_config: ci-v8 platform: linux_amd64_musl sandbox: true target: x86_64-unknown-linux-musl - v8_cpu: x64 variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: false target: aarch64-unknown-linux-musl - v8_cpu: arm64 variant: release - runner: ubuntu-24.04-arm bazel_config: ci-v8 platform: linux_arm64_musl sandbox: true target: aarch64-unknown-linux-musl - v8_cpu: arm64 variant: ptrcomp-sandbox steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -196,7 +184,6 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} - V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -211,14 +198,9 @@ jobs: bazel_args=( build "--platforms=@llvm//platforms:${PLATFORM}" - --config=rusty-v8-upstream-libcxx - "--config=v8-target-${V8_CPU}" "${pair_target}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) - if [[ "${SANDBOX}" != "true" ]]; then - bazel_args+=(--config=v8-release-compat) - fi ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ @@ -232,7 +214,6 @@ jobs: PLATFORM: ${{ matrix.platform }} SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} - V8_CPU: ${{ matrix.v8_cpu }} shell: bash run: | set -euo pipefail @@ -242,12 +223,9 @@ jobs: --target "${TARGET}" --output-dir "dist/${TARGET}" --bazel-config "${BAZEL_CONFIG}" - --bazel-config "v8-target-${V8_CPU}" ) if [[ "${SANDBOX}" == "true" ]]; then stage_args+=(--sandbox) - else - stage_args+=(--bazel-config v8-release-compat) fi python3 .github/scripts/rusty_v8_bazel.py stage-release-pair "${stage_args[@]}" diff --git a/third_party/v8/BUILD.bazel b/third_party/v8/BUILD.bazel index ebc2a802be9e..2660805f9b14 100644 --- a/third_party/v8/BUILD.bazel +++ b/third_party/v8/BUILD.bazel @@ -1,3 +1,4 @@ +load("@@//third_party/v8:artifact_profile.bzl", "v8_artifact_files") load("@bazel_skylib//rules:copy_file.bzl", "copy_file") load("@rules_cc//cc:cc_static_library.bzl", "cc_static_library") load("@rules_cc//cc:defs.bzl", "cc_library") @@ -7,6 +8,7 @@ package(default_visibility = ["//visibility:public"]) exports_files([ "abseil.BUILD.bazel", "abseil_repository.bzl", + "artifact_profile.bzl", "dependencies.bzl", "dependency_config.bzl", "dragonbox.BUILD.bazel", @@ -83,16 +85,13 @@ alias( actual = ":src_binding_release_aarch64_unknown_linux_gnu_152_2_0_release", ) -# Published files contain both pointer compression and the in-process sandbox. -# Use the source pair together whenever those options differ or a caller needs -# custom V8 C++ flags. The main-repo flag is shared with the @v8_targets copy -# of this BUILD file used by rules_rs. +# Both ordinary consumers and source consumers use the current sandbox ABI. +# Switch the archive and binding together for source instrumentation. The same +# root setting is visible in the @v8_targets copy used by rules_rs. config_setting( name = "use_rusty_v8_prebuilts", flag_values = { "@//:rusty_v8_from_source": "False", - "@v8//:v8_enable_pointer_compression": "True", - "@v8//:v8_enable_sandbox": "True", }, ) @@ -315,8 +314,9 @@ cc_library( ) cc_static_library( - name = "v8_152_2_0_aarch64_apple_darwin_bazel", + name = "_v8_152_2_0_aarch64_apple_darwin_bazel", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [ ":rusty_v8_custom_libcxx_runtime", ":v8_152_2_0_binding", @@ -324,8 +324,9 @@ cc_static_library( ) cc_static_library( - name = "v8_152_2_0_aarch64_unknown_linux_gnu_bazel", + name = "_v8_152_2_0_aarch64_unknown_linux_gnu_bazel", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [ ":rusty_v8_custom_libcxx_runtime", ":v8_152_2_0_binding", @@ -333,14 +334,16 @@ cc_static_library( ) cc_static_library( - name = "v8_152_2_0_aarch64_pc_windows_gnullvm_bazel", + name = "_v8_152_2_0_aarch64_pc_windows_gnullvm_bazel", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [":v8_152_2_0_binding"], ) cc_static_library( - name = "v8_152_2_0_aarch64_unknown_linux_musl_release_base", + name = "_v8_152_2_0_aarch64_unknown_linux_musl_release_base", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [ ":rusty_v8_custom_libcxx_runtime", ":v8_152_2_0_binding", @@ -348,16 +351,16 @@ cc_static_library( ) genrule( - name = "v8_152_2_0_aarch64_unknown_linux_musl_release", + name = "_v8_152_2_0_aarch64_unknown_linux_musl_release", srcs = [ - ":v8_152_2_0_aarch64_unknown_linux_musl_release_base", + ":_v8_152_2_0_aarch64_unknown_linux_musl_release_base", "@llvm//runtimes/compiler-rt:clang_rt.builtins.static", ], outs = ["libv8_152_2_0_aarch64_unknown_linux_musl.a"], cmd = """ cat > "$(@D)/merge.mri" <<'EOF' create $@ -addlib $(location :v8_152_2_0_aarch64_unknown_linux_musl_release_base) +addlib $(location :_v8_152_2_0_aarch64_unknown_linux_musl_release_base) addlib $(location @llvm//runtimes/compiler-rt:clang_rt.builtins.static) save end @@ -369,11 +372,13 @@ EOF "@llvm//tools:llvm-ar", "@llvm//tools:llvm-ranlib", ], + visibility = ["//visibility:private"], ) cc_static_library( - name = "v8_152_2_0_x86_64_apple_darwin_bazel", + name = "_v8_152_2_0_x86_64_apple_darwin_bazel", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [ ":rusty_v8_custom_libcxx_runtime", ":v8_152_2_0_binding", @@ -381,8 +386,9 @@ cc_static_library( ) cc_static_library( - name = "v8_152_2_0_x86_64_unknown_linux_gnu_bazel", + name = "_v8_152_2_0_x86_64_unknown_linux_gnu_bazel", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [ ":rusty_v8_custom_libcxx_runtime", ":v8_152_2_0_binding", @@ -390,20 +396,44 @@ cc_static_library( ) cc_static_library( - name = "v8_152_2_0_x86_64_pc_windows_gnullvm_bazel", + name = "_v8_152_2_0_x86_64_pc_windows_gnullvm_bazel", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [":v8_152_2_0_binding"], ) cc_static_library( - name = "v8_152_2_0_x86_64_unknown_linux_musl_release", + name = "_v8_152_2_0_x86_64_unknown_linux_musl_release", features = V8_STATIC_LIBRARY_FEATURES, + visibility = ["//visibility:private"], deps = [ ":rusty_v8_custom_libcxx_runtime", ":v8_152_2_0_binding", ], ) +# Local source producers match the Rust consumer's sandbox ABI, and use the +# workspace C++ runtime. Release pairs below select pinned Chromium libc++ and +# build the private source nodes directly under their own profiles. +[ + v8_artifact_files( + name = name, + srcs = [":_" + name], + profile = "source", + v8_cpu = cpu, + ) + for name, cpu in [ + ("v8_152_2_0_aarch64_apple_darwin_bazel", "arm64"), + ("v8_152_2_0_aarch64_unknown_linux_gnu_bazel", "arm64"), + ("v8_152_2_0_aarch64_pc_windows_gnullvm_bazel", "arm64"), + ("v8_152_2_0_aarch64_unknown_linux_musl_release", "arm64"), + ("v8_152_2_0_x86_64_apple_darwin_bazel", "x64"), + ("v8_152_2_0_x86_64_unknown_linux_gnu_bazel", "x64"), + ("v8_152_2_0_x86_64_pc_windows_gnullvm_bazel", "x64"), + ("v8_152_2_0_x86_64_unknown_linux_musl_release", "x64"), + ] +] + filegroup( name = "src_binding_release_aarch64_apple_darwin_152_2_0_release", srcs = ["@v8_crate_152_2_0//:src_binding_release_aarch64_apple_darwin"], @@ -444,52 +474,64 @@ alias( actual = "@rusty_v8_152_2_0_x86_64_pc_windows_msvc_binding//file", ) -filegroup( +v8_artifact_files( name = "rusty_v8_release_pair_x86_64_apple_darwin", srcs = [ + ":_v8_152_2_0_x86_64_apple_darwin_bazel", ":src_binding_release_x86_64_apple_darwin_152_2_0_release", - ":v8_152_2_0_x86_64_apple_darwin_bazel", ], + profile = "release", + v8_cpu = "x64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_release_pair_aarch64_apple_darwin", srcs = [ + ":_v8_152_2_0_aarch64_apple_darwin_bazel", ":src_binding_release_aarch64_apple_darwin_152_2_0_release", - ":v8_152_2_0_aarch64_apple_darwin_bazel", ], + profile = "release", + v8_cpu = "arm64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_release_pair_x86_64_unknown_linux_gnu", srcs = [ + ":_v8_152_2_0_x86_64_unknown_linux_gnu_bazel", ":src_binding_release_x86_64_unknown_linux_gnu_152_2_0_release", - ":v8_152_2_0_x86_64_unknown_linux_gnu_bazel", ], + profile = "release", + v8_cpu = "x64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_release_pair_aarch64_unknown_linux_gnu", srcs = [ + ":_v8_152_2_0_aarch64_unknown_linux_gnu_bazel", ":src_binding_release_aarch64_unknown_linux_gnu_152_2_0_release", - ":v8_152_2_0_aarch64_unknown_linux_gnu_bazel", ], + profile = "release", + v8_cpu = "arm64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_release_pair_x86_64_unknown_linux_musl", srcs = [ + ":_v8_152_2_0_x86_64_unknown_linux_musl_release", ":src_binding_release_x86_64_unknown_linux_musl_152_2_0_release", - ":v8_152_2_0_x86_64_unknown_linux_musl_release", ], + profile = "release", + v8_cpu = "x64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_release_pair_aarch64_unknown_linux_musl", srcs = [ + ":_v8_152_2_0_aarch64_unknown_linux_musl_release", ":src_binding_release_aarch64_unknown_linux_musl_152_2_0_release", - ":v8_152_2_0_aarch64_unknown_linux_musl_release", ], + profile = "release", + v8_cpu = "arm64", ) filegroup( @@ -508,50 +550,62 @@ filegroup( ], ) -filegroup( +v8_artifact_files( name = "rusty_v8_sandbox_release_pair_x86_64_apple_darwin", srcs = [ + ":_v8_152_2_0_x86_64_apple_darwin_bazel", ":src_binding_release_x86_64_apple_darwin_152_2_0_release", - ":v8_152_2_0_x86_64_apple_darwin_bazel", ], + profile = "sandbox_release", + v8_cpu = "x64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_sandbox_release_pair_aarch64_apple_darwin", srcs = [ + ":_v8_152_2_0_aarch64_apple_darwin_bazel", ":src_binding_release_aarch64_apple_darwin_152_2_0_release", - ":v8_152_2_0_aarch64_apple_darwin_bazel", ], + profile = "sandbox_release", + v8_cpu = "arm64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_sandbox_release_pair_x86_64_unknown_linux_gnu", srcs = [ + ":_v8_152_2_0_x86_64_unknown_linux_gnu_bazel", ":src_binding_release_x86_64_unknown_linux_gnu_152_2_0_release", - ":v8_152_2_0_x86_64_unknown_linux_gnu_bazel", ], + profile = "sandbox_release", + v8_cpu = "x64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_sandbox_release_pair_aarch64_unknown_linux_gnu", srcs = [ + ":_v8_152_2_0_aarch64_unknown_linux_gnu_bazel", ":src_binding_release_aarch64_unknown_linux_gnu_152_2_0_release", - ":v8_152_2_0_aarch64_unknown_linux_gnu_bazel", ], + profile = "sandbox_release", + v8_cpu = "arm64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", srcs = [ + ":_v8_152_2_0_x86_64_unknown_linux_musl_release", ":src_binding_release_x86_64_unknown_linux_musl_152_2_0_release", - ":v8_152_2_0_x86_64_unknown_linux_musl_release", ], + profile = "sandbox_release", + v8_cpu = "x64", ) -filegroup( +v8_artifact_files( name = "rusty_v8_sandbox_release_pair_aarch64_unknown_linux_musl", srcs = [ + ":_v8_152_2_0_aarch64_unknown_linux_musl_release", ":src_binding_release_aarch64_unknown_linux_musl_152_2_0_release", - ":v8_152_2_0_aarch64_unknown_linux_musl_release", ], + profile = "sandbox_release", + v8_cpu = "arm64", ) diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 9c60403e9845..b5ddd23521d4 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -16,11 +16,13 @@ for its consumers. All Bazel compilation modes use the same published V8 release archive on supported platforms. The Bazel `v8` crate feature selection enables V8's in-process sandbox. -Darwin/Linux consumers select prebuilts only when both the V8 sandbox and pointer -compression settings match the published artifact. For source instrumentation -or custom V8 C++ settings, use `--//:rusty_v8_from_source=true`; the archive -and binding then both come from the source path. The published release archive -cannot incorporate local V8 C++ flags or native debug/sanitizer settings. +Darwin/Linux consumers use the matching published pair by default. +For a source build use `--//:rusty_v8_from_source=true`; both archive and +bindings then come from the source path. Source targets enforce the workspace +runtime and sandbox/pointer-compression ABI. Release-pair targets enforce either +the legacy or the sandbox ABI plus Chromium's pinned runtime. Target CPU follows +the named artifact. The underlying V8 settings are internal, and callers do +not pass separate V8 feature or libc++ flags. Current pinned versions: @@ -110,10 +112,10 @@ from upstream `rusty_v8` source. Those ABI-specific outputs cannot be produced by Codex's Bazel Windows GNU toolchain. The Bazel graph pins the same libc++, libc++abi, and llvm-libc source revisions -used by `rusty_v8 v152.2.0`, compiles published artifact targets with -`--config=rusty-v8-upstream-libcxx`, and folds the matching runtime objects into -the final static archive so consumers can link it with the `v8` crate's default -`use_custom_libcxx` feature. The config keeps the object files and the bundled +used by `rusty_v8 v152.2.0`. The named release and sandbox-release pair targets +select those headers and fold the matching runtime objects into the final static +archive so consumers can link it with the `v8` crate's default +`use_custom_libcxx` feature. The profile keeps the object files and the bundled runtime on Chromium's `std::__Cr` ABI namespace instead of mixing those objects with the toolchain libc++ default namespace. Bazel consumers use these published archives for supported Darwin/Linux platforms, as do Cargo release and @@ -157,14 +159,16 @@ each fetch, including added, removed or reordered groups. The importer rejects missing boundaries, malformed or empty sections, and new compiled targets. Only the two known Dragonbox and FP16 declarations are skipped, since the dependency overlays already supply them. `v8_binary` is valid V8 syntax, but compiled targets -must be reviewed against the owned engine BUILD on an upgrade. The -upstream `bazel/config` platform and CPU package is reused unchanged. -`v8_defs.patch` records just the Codex differences to upstream's -`bazel/defs.bzl`: release flag visibility, libc++ headers/runtime, Windows +must be reviewed against the owned engine BUILD on an upgrade. +The upstream `bazel/config` platform and CPU package is reused; only the CPU +setting changes from a CLI flag to an internal setting. `v8_defs.patch` records +that change and the Codex differences to upstream's `bazel/defs.bzl`: +internal ABI/runtime settings, their visibility, libc++ headers/runtime, Windows compiler and linker settings, and snapshot generation. All engine rules, including Torque and V8's test configuration helpers, load from that file. -The engine BUILD owns feature policy and dependency targets; the remaining V8 -ABI, runtime and CPU settings still support the existing release commands. +The engine BUILD owns feature policy and dependency targets. ABI, runtime and +CPU settings are selected together by `artifact_profile.bzl` for source and +release-pair targets. V8's private Abseil is built from the pinned 20250814.1 sources and uses `codex_v8_lts_20250814` for its supported inline-namespace option. Protobuf diff --git a/third_party/v8/artifact_profile.bzl b/third_party/v8/artifact_profile.bzl new file mode 100644 index 000000000000..506ff27b9e9a --- /dev/null +++ b/third_party/v8/artifact_profile.bzl @@ -0,0 +1,76 @@ +"""Complete V8 artifact profiles, enforced at the source archive boundary. + +The source profile is for local consumers and uses the workspace C++ runtime. +Published profiles embed the pinned Chromium runtime. Generators inherit the +V8 target CPU even when their executable is built for the exec platform. +""" + +_POINTER_COMPRESSION = "@v8//:v8_enable_pointer_compression" +_SANDBOX = "@v8//:v8_enable_sandbox" +_LIBCXX = "@v8//:v8_use_rusty_v8_custom_libcxx" +_TARGET_CPU = "@v8//bazel/config:v8_target_cpu" +_CPU_CONSTRAINTS = { + "arm64": Label("@platforms//cpu:aarch64"), + "x64": Label("@platforms//cpu:x86_64"), +} + +def _artifact_profile_impl(_settings, attr): + sandbox = attr.profile != "release" + return { + _POINTER_COMPRESSION: "True" if sandbox else "False", + _SANDBOX: sandbox, + _LIBCXX: attr.profile != "source", + _TARGET_CPU: attr.v8_cpu, + } + +_artifact_profile = transition( + implementation = _artifact_profile_impl, + inputs = [], + outputs = [_POINTER_COMPRESSION, _SANDBOX, _LIBCXX, _TARGET_CPU], +) + +def _profiled_files_impl(ctx): + constraint_target = ctx.attr._arm64 if ctx.attr.v8_cpu == "arm64" else ctx.attr._x64 + if not ctx.target_platform_has_constraint(constraint_target[platform_common.ConstraintValueInfo]): + fail("%s: V8 CPU %s requires %s on the Bazel target platform. Choose a matching artifact target and --platforms." % ( + ctx.label, + ctx.attr.v8_cpu, + constraint_target.label, + )) + files = depset(transitive = [target[DefaultInfo].files for target in ctx.attr.srcs]) + runfiles = ctx.runfiles().merge_all([ + target[DefaultInfo].default_runfiles + for target in ctx.attr.srcs + ]) + return [DefaultInfo(files = files, runfiles = runfiles)] + +_v8_artifact_files = rule( + implementation = _profiled_files_impl, + doc = "Build a source archive or release pair under its named V8 profile.", + attrs = { + "profile": attr.string( + mandatory = True, + values = ["source", "release", "sandbox_release"], + ), + "srcs": attr.label_list(allow_files = True, cfg = _artifact_profile, mandatory = True), + "v8_cpu": attr.string(mandatory = True, values = ["arm64", "x64"]), + "_allowlist_function_transition": attr.label( + default = "@bazel_tools//tools/allowlists/function_transition_allowlist", + ), + "_arm64": attr.label(default = _CPU_CONSTRAINTS["arm64"]), + "_x64": attr.label(default = _CPU_CONSTRAINTS["x64"]), + }, +) + +def v8_artifact_files(name, v8_cpu, **kwargs): + """Declare an artifact that is only valid for its requested CPU. + + Fixed-CPU targets without explicit platform constraints can be skipped in + wildcard builds. Preserve constraints on targets such as native Windows + which already specify their CPU and ABI. The rule checks the resolved CPU + in both cases, including values chosen by select(). + The generator exec platform remains independent of this target constraint. + """ + if type(v8_cpu) == "string": + kwargs.setdefault("target_compatible_with", [_CPU_CONSTRAINTS[v8_cpu]]) + _v8_artifact_files(name = name, v8_cpu = v8_cpu, **kwargs) diff --git a/third_party/v8/engine.BUILD.bazel b/third_party/v8/engine.BUILD.bazel index dba5eca64d24..610aee935041 100644 --- a/third_party/v8/engine.BUILD.bazel +++ b/third_party/v8/engine.BUILD.bazel @@ -23,8 +23,8 @@ load("@v8_python_deps//:requirements.bzl", "requirement") # Used by the Codex-owned pip hub; pin changes travel with the V8 source. exports_files(["bazel/requirements.txt"]) -# Codex V8 source policy. Only ABI, runtime and target CPU vary; these still -# accept the existing release inputs. CPU selection lives in bazel/config. +# Codex V8 source policy. Only ABI, runtime and target CPU vary, selected by +# artifact profiles. CPU selection lives in bazel/config. # Upstream experiments, profiling and debug flags use their pinned 15.2 defaults. # ICU and no-ICU remain separate targets for the engine and its generators. v8_flag(name = "v8_enable_sandbox") diff --git a/third_party/v8/engine_repository.bzl b/third_party/v8/engine_repository.bzl index dd6b966623bd..791a73dd08d1 100644 --- a/third_party/v8/engine_repository.bzl +++ b/third_party/v8/engine_repository.bzl @@ -2,7 +2,8 @@ The root package is placed inside the fetched source tree so relative source labels and globs have their normal Bazel semantics. Reuse upstream's platform -and CPU configuration in place, with Codex's narrow patch to bazel/defs.bzl. +and CPU configuration in place, with Codex's narrow patch to bazel/defs.bzl +and the target CPU setting. V8's MODULE and toolchain BUILD files are never part of Codex's dependency or toolchain graph. """ diff --git a/third_party/v8/engine_rules.bzl b/third_party/v8/engine_rules.bzl index 87261f33aa92..5d235c56da3d 100644 --- a/third_party/v8/engine_rules.bzl +++ b/third_party/v8/engine_rules.bzl @@ -6,7 +6,7 @@ def _options_impl(ctx): return FlagInfo(value = ctx.build_setting_value) # Source-only features keep upstream select names without adding CLI options. -# ABI/runtime settings are still V8 release inputs until artifact profiles take over. +# ABI/runtime selections are internal settings controlled by artifact profiles. v8_fixed_source_features = rule( implementation = _options_impl, build_setting = config.bool(flag = False), diff --git a/third_party/v8/v8_defs.patch b/third_party/v8/v8_defs.patch index fea274539dd0..1fe5b019f46f 100644 --- a/third_party/v8/v8_defs.patch +++ b/third_party/v8/v8_defs.patch @@ -1,13 +1,34 @@ # What: adapt the pinned V8 rule helpers to Codex's engine configuration. -# Scope: bazel/defs.bzl only; the engine graph and dependency choices live in -# third_party/v8/engine.BUILD.bazel. The platform/CPU config is unmodified V8. -# Preserve the existing release flags, Chromium libc++ headers/runtime, Clang -# Windows driver flags, and Windows target-ABI snapshot tool. The optional +# Scope: bazel/defs.bzl and bazel/config/v8-target-cpu.bzl; the engine graph +# and dependency choices live in third_party/v8/engine.BUILD.bazel. ABI, +# runtime and CPU settings are internal outputs of artifact_profile.bzl. +# Preserve Chromium libc++ headers/runtime, Clang Windows driver flags, and +# the Windows target-ABI snapshot tool. The optional # Linux backtrace link flag is omitted because that feature is fixed off. # On a V8 pin change, review these hunks and build the native canary matrix. +diff --git a/orig/v8-15.2.124.1/bazel/defs.bzl b/mod/v8-15.2.124.1/bazel/defs.bzl --- a/orig/v8-15.2.124.1/bazel/defs.bzl +++ b/mod/v8-15.2.124.1/bazel/defs.bzl +@@ -18,16 +18,16 @@ + return FlagInfo(value = ctx.build_setting_value) + + _create_option_flag = rule( + implementation = _options_impl, +- build_setting = config.bool(flag = True), ++ build_setting = config.bool(flag = False), + attrs = { + "scope": attr.string(), + }, + ) + + _create_option_string = rule( + implementation = _options_impl, +- build_setting = config.string(flag = True), ++ build_setting = config.string(flag = False), + attrs = { + "scope": attr.string(), + }, @@ -42,12 +42,30 @@ ) @@ -174,3 +195,17 @@ suffix = suffix, target_os = select({ "@v8//bazel/config:is_macos": "mac", +diff --git a/orig/v8-15.2.124.1/bazel/config/v8-target-cpu.bzl b/mod/v8-15.2.124.1/bazel/config/v8-target-cpu.bzl +--- a/orig/v8-15.2.124.1/bazel/config/v8-target-cpu.bzl ++++ b/mod/v8-15.2.124.1/bazel/config/v8-target-cpu.bzl +@@ -24,8 +24,8 @@ + + v8_target_cpu = rule( + implementation = _host_target_cpu_impl, +- build_setting = config.string(flag = True), +- doc = "CPU that V8 will generate code for.", ++ build_setting = config.string(flag = False), ++ doc = "Internal CPU that V8 generates code for; set by the artifact transition.", + attrs = { + "scope": attr.string(), + }, From 2d3de3783460a57f1e089b383f8d5d4870a41779 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Fri, 23 Oct 2026 18:40:55 +0000 Subject: [PATCH 003/287] Surface automatic marketplace update failures as startup warnings (#57166) ## Why Automatic marketplace update failures were only logged, leaving users without a visible startup warning. ## What changed Send marketplace update errors and task startup failures through app-server warning notifications. Queue background warnings until a client initializes in both standalone and in-process servers, and centralize startup configuration warning delivery. ## Testing Add a TUI integration test and snapshot covering two marketplace name mismatch failures, the warning count, and navigation between their details with `F2` and the arrow keys. GitOrigin-RevId: af55a5555affd9d89ac15a546163daeaafd3b3e8 --- codex-rs/app-server/src/in_process.rs | 5 + codex-rs/app-server/src/lib.rs | 6 + codex-rs/app-server/src/message_processor.rs | 36 +++--- .../src/message_processor_tracing_tests.rs | 1 + ...ssage_processor_workspace_routing_tests.rs | 1 + .../initialize_processor.rs | 25 ---- codex-rs/app-server/src/startup_warnings.rs | 39 +++++++ .../src/user_verification_test_support.rs | 1 + codex-rs/core-plugins/src/manager.rs | 12 ++ codex-rs/tui/src/app/tests.rs | 2 + .../app/tests/marketplace_upgrade_tests.rs | 110 ++++++++++++++++++ ...de_tests__marketplace_upgrade_warning.snap | 57 +++++++++ 12 files changed, 256 insertions(+), 39 deletions(-) create mode 100644 codex-rs/app-server/src/startup_warnings.rs create mode 100644 codex-rs/tui/src/app/tests/marketplace_upgrade_tests.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__marketplace_upgrade_tests__marketplace_upgrade_warning.snap diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index b61c78e8e129..c9576732b02b 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -500,6 +500,7 @@ async fn start_uninitialized(mut args: InProcessStartArgs) -> IoResult(channel_capacity); let mut processor_handle = tokio::spawn(async move { + let (startup_warnings_tx, mut startup_warnings_rx) = mpsc::unbounded_channel(); let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { cloud_skill_provider: None, outgoing: Arc::clone(&processor_outgoing), @@ -513,6 +514,7 @@ async fn start_uninitialized(mut args: InProcessStartArgs) -> IoResult IoResult { + crate::startup_warnings::send_warning(&processor_outgoing, message).await; + } created = thread_created_rx.recv(), if listen_for_threads => { match created { Ok(thread_id) => { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ae5941119fd4..8abfa4aa5dce 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -143,6 +143,7 @@ mod request_processors; mod request_serialization; mod server_request_error; mod skills_watcher; +mod startup_warnings; mod thread_sample; mod thread_state; mod thread_status; @@ -1007,6 +1008,7 @@ pub async fn run_main_with_transport_options( let auth_manager = Arc::clone(&auth_manager); let initialize_notification_sender = outgoing_message_sender.clone(); let outbound_control_tx = outbound_control_tx; + let (startup_warnings_tx, mut startup_warnings_rx) = mpsc::unbounded_channel(); let mut processor = MessageProcessor::new(MessageProcessorArgs { cloud_skill_provider, outgoing: outgoing_message_sender, @@ -1020,6 +1022,7 @@ pub async fn run_main_with_transport_options( log_db, state_db: state_db.clone(), config_warnings, + startup_warnings_tx, session_source, user_verification: Arc::new(crate::user_verification::Service::new(Arc::clone( &auth_manager, @@ -1301,6 +1304,9 @@ pub async fn run_main_with_transport_options( } } } + Some(message) = startup_warnings_rx.recv(), if connections.values().any(|state| state.session.initialized()) => { + startup_warnings::send_warning(&initialize_notification_sender, message).await; + } _ = connection_cleanup_tasks.reap_next() => {} result = thread_listener_tasks.join_next(), if !thread_listener_tasks.is_empty() => { if let Some(Err(err)) = result { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index a1b1133119be..3162839f08ce 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -55,6 +55,7 @@ use crate::request_serialization::QueuedInitializedRequest; use crate::request_serialization::RequestSerializationQueueKey; use crate::request_serialization::RequestSerializationQueues; use crate::skills_watcher::SkillsWatcher; +use crate::startup_warnings::StartupWarnings; use crate::thread_state::ConnectionCapabilities; use crate::thread_state::ThreadStateManager; use crate::transport::AppServerTransport; @@ -99,6 +100,7 @@ use codex_thread_store::QueueStore; use tokio::sync::Mutex; use tokio::sync::Semaphore; use tokio::sync::broadcast; +use tokio::sync::mpsc; use tokio::sync::watch; use tokio::time::Duration; use tokio::time::timeout; @@ -145,6 +147,7 @@ pub(crate) struct MessageProcessor { pub(crate) turn_admission: TurnAdmission, user_verification: Arc, outgoing: Arc, + startup_warnings: StartupWarnings, models_refresh_worker: ModelsRefreshWorker, turn_cost_worker: Option, skills_watcher: Arc, @@ -262,6 +265,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) log_db: Option, pub(crate) state_db: Option, pub(crate) config_warnings: Vec, + pub(crate) startup_warnings_tx: mpsc::UnboundedSender, pub(crate) session_source: SessionSource, pub(crate) auth_manager: Arc, pub(crate) user_verification: Arc, @@ -290,6 +294,7 @@ impl MessageProcessor { log_db, state_db, config_warnings, + startup_warnings_tx, session_source, auth_manager, user_verification, @@ -473,15 +478,6 @@ impl MessageProcessor { state_db.clone(), ); let git_processor = GitRequestProcessor::new(); - let initialize_processor = InitializeRequestProcessor::new( - gateway_login_control, - outgoing.clone(), - analytics_events_client.clone(), - Arc::clone(&config), - config_warnings.clone(), - rpc_transport, - Arc::clone(&user_verification), - ); let marketplace_processor = MarketplaceRequestProcessor::new(config_manager.clone(), Arc::clone(&thread_manager)); let mcp_processor = McpRequestProcessor::new( @@ -540,7 +536,7 @@ impl MessageProcessor { log_db, Arc::clone(&skills_watcher), turn_cost_worker.as_ref().map(TurnCostWorker::handle), - config_warnings, + config_warnings.clone(), sample_responses, ); let turn_processor = TurnRequestProcessor::new( @@ -574,8 +570,17 @@ impl MessageProcessor { &config.plugins_config_input(), reload_config, Some(on_effective_plugins_changed), + startup_warnings_tx, ); } + let initialize_processor = InitializeRequestProcessor::new( + gateway_login_control, + outgoing.clone(), + analytics_events_client.clone(), + Arc::clone(&config), + rpc_transport, + Arc::clone(&user_verification), + ); let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(ExternalAgentConfigRequestProcessorArgs { outgoing: outgoing.clone(), @@ -604,6 +609,9 @@ impl MessageProcessor { turn_admission, user_verification, outgoing, + startup_warnings: StartupWarnings { + config: config_warnings, + }, models_refresh_worker, turn_cost_worker, skills_watcher, @@ -822,8 +830,8 @@ impl MessageProcessor { &self, connection_id: ConnectionId, ) { - self.initialize_processor - .send_initialize_notifications_to_connection(connection_id) + self.startup_warnings + .send_config_to_connections(&self.outgoing, &[connection_id]) .await; } @@ -845,8 +853,8 @@ impl MessageProcessor { } pub(crate) async fn send_initialize_notifications(&self) { - self.initialize_processor - .send_initialize_notifications() + self.startup_warnings + .send_config_to_connections(&self.outgoing, &[]) .await; } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 2bb77bf6fb4e..aeff494f090b 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -273,6 +273,7 @@ pub(super) async fn build_test_processor( log_db: None, state_db: None, config_warnings: Vec::new(), + startup_warnings_tx: mpsc::unbounded_channel().0, session_source: SessionSource::VSCode, user_verification: Arc::new(crate::user_verification::Service::new(Arc::clone( &auth_manager, diff --git a/codex-rs/app-server/src/message_processor_workspace_routing_tests.rs b/codex-rs/app-server/src/message_processor_workspace_routing_tests.rs index cfb49bea5b97..e3f911f6dbb2 100644 --- a/codex-rs/app-server/src/message_processor_workspace_routing_tests.rs +++ b/codex-rs/app-server/src/message_processor_workspace_routing_tests.rs @@ -76,6 +76,7 @@ async fn background_routing_call_sites_skip_config(api_key: Option<&str>) -> any log_db: None, state_db: None, config_warnings: Vec::new(), + startup_warnings_tx: mpsc::unbounded_channel().0, session_source: SessionSource::VSCode, user_verification: Arc::new(crate::user_verification::Service::new(Arc::clone( &auth_manager, diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index d68a29df9f36..6259cf7488f7 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -25,7 +25,6 @@ pub(crate) struct InitializeRequestProcessor { outgoing: Arc, analytics_events_client: AnalyticsEventsClient, config: Arc, - config_warnings: Arc>, rpc_transport: AppServerRpcTransport, user_verification: Arc, } @@ -37,7 +36,6 @@ impl InitializeRequestProcessor { outgoing: Arc, analytics_events_client: AnalyticsEventsClient, config: Arc, - config_warnings: Vec, rpc_transport: AppServerRpcTransport, user_verification: Arc, ) -> Self { @@ -47,7 +45,6 @@ impl InitializeRequestProcessor { outgoing, analytics_events_client, config, - config_warnings: Arc::new(config_warnings), rpc_transport, user_verification, } @@ -229,28 +226,6 @@ impl InitializeRequestProcessor { Ok(false) } - pub(crate) async fn send_initialize_notifications_to_connection( - &self, - connection_id: ConnectionId, - ) { - for notification in self.config_warnings.iter().cloned() { - self.outgoing - .send_server_notification_to_connections( - &[connection_id], - ServerNotification::ConfigWarning(notification), - ) - .await; - } - } - - pub(crate) async fn send_initialize_notifications(&self) { - for notification in self.config_warnings.iter().cloned() { - self.outgoing - .send_server_notification(ServerNotification::ConfigWarning(notification)) - .await; - } - } - pub(crate) fn track_initialized_request( &self, connection_id: ConnectionId, diff --git a/codex-rs/app-server/src/startup_warnings.rs b/codex-rs/app-server/src/startup_warnings.rs new file mode 100644 index 000000000000..b2eec2f6b2b4 --- /dev/null +++ b/codex-rs/app-server/src/startup_warnings.rs @@ -0,0 +1,39 @@ +//! Delivers configuration and background task warnings after a client initializes. +//! The runtime event loop owns the receiver and consumes background warnings once. + +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::WarningNotification; + +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; + +pub(crate) struct StartupWarnings { + pub(crate) config: Vec, +} + +impl StartupWarnings { + pub(crate) async fn send_config_to_connections( + &self, + outgoing: &OutgoingMessageSender, + connection_ids: &[ConnectionId], + ) { + for notification in self.config.iter().cloned() { + outgoing + .send_server_notification_to_connections( + connection_ids, + ServerNotification::ConfigWarning(notification), + ) + .await; + } + } +} + +pub(crate) async fn send_warning(outgoing: &OutgoingMessageSender, message: String) { + outgoing + .send_server_notification(ServerNotification::Warning(WarningNotification { + thread_id: None, + message, + })) + .await; +} diff --git a/codex-rs/app-server/src/user_verification_test_support.rs b/codex-rs/app-server/src/user_verification_test_support.rs index 28a35ca1324d..1a392d102c0e 100644 --- a/codex-rs/app-server/src/user_verification_test_support.rs +++ b/codex-rs/app-server/src/user_verification_test_support.rs @@ -166,6 +166,7 @@ impl Harness { log_db: None, state_db: None, config_warnings: Vec::new(), + startup_warnings_tx: mpsc::unbounded_channel().0, session_source: SessionSource::Cli, auth_manager: Arc::clone(&auth), user_verification: Arc::clone(&service), diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index bad84811270b..6d9d676258fc 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -145,6 +145,7 @@ use std::time::Instant; use tokio::sync::OnceCell; use tokio::sync::OwnedSemaphorePermit; use tokio::sync::Semaphore; +use tokio::sync::mpsc; use tokio::sync::watch; use tracing::instrument; use tracing::warn; @@ -2869,11 +2870,13 @@ impl PluginsManager { }) } + /// Starts background tasks and sends marketplace upgrade warnings through the supplied channel. pub fn maybe_start_plugin_startup_tasks_for_config( self: &Arc, config: &PluginsConfigInput, reload_config: ConfigLayerReload, on_effective_plugins_changed: Option, + upgrade_warnings: mpsc::UnboundedSender, ) { if config.plugins_enabled { self.maybe_start_curated_repo_sync_for_config( @@ -2897,6 +2900,7 @@ impl PluginsManager { let config = config.clone(); let on_effective_plugins_changed = on_effective_plugins_changed.clone(); let runtime = tokio::runtime::Handle::current(); + let task_warnings = upgrade_warnings.clone(); if let Err(err) = std::thread::Builder::new() .name("plugins-marketplace-auto-upgrade".to_string()) .spawn(move || { @@ -2924,10 +2928,16 @@ impl PluginsManager { error = %error.message, "failed to auto-upgrade configured marketplace" ); + let _ = task_warnings.send(format!( + "Codex could not update marketplace `{}`: {}", + error.marketplace_name, error.message + )); } } Err(err) => { warn!("failed to auto-upgrade configured marketplaces: {err}"); + let _ = task_warnings + .send(format!("Codex could not update marketplaces: {err}")); } } @@ -2944,6 +2954,8 @@ impl PluginsManager { }; state.in_flight = false; warn!("failed to start configured marketplace auto-upgrade task: {err}"); + let _ = upgrade_warnings + .send(format!("Codex could not start marketplace updates: {err}")); } } let config_for_remote_sync = config.clone(); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ae4532818adc..c0c378b16d2a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -51,6 +51,8 @@ mod key_chords; mod local_command_scroll_tests; #[path = "tests/luna_reserve_recovery_tests.rs"] mod luna_reserve_recovery_tests; +#[path = "tests/marketplace_upgrade_tests.rs"] +mod marketplace_upgrade_tests; #[path = "tests/mcp_startup.rs"] mod mcp_startup; #[path = "tests/misalignment_policy_tests.rs"] diff --git a/codex-rs/tui/src/app/tests/marketplace_upgrade_tests.rs b/codex-rs/tui/src/app/tests/marketplace_upgrade_tests.rs new file mode 100644 index 000000000000..173268cb0dc1 --- /dev/null +++ b/codex-rs/tui/src/app/tests/marketplace_upgrade_tests.rs @@ -0,0 +1,110 @@ +//! Marketplace startup failures reach the warning notice and its details view. + +use super::*; +use crate::app::owned_transcript::tests::buffer_text; +use crate::chatwidget::tests::helpers::normalize_snapshot_paths; +use crate::custom_terminal::test_support::last_rendered_buffer; +use codex_app_server_client::AppServerEvent; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; + +#[tokio::test] +async fn automatic_marketplace_upgrade_failure_shows_warning() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let source = tempdir()?; + std::fs::create_dir_all(source.path().join(".agents/plugins"))?; + std::fs::write( + source.path().join(".agents/plugins/marketplace.json"), + r#"{"name":"other","plugins":[]}"#, + )?; + for args in [ + vec!["init"], + vec!["add", "."], + vec!["commit", "-m", "initial marketplace"], + ] { + let output = std::process::Command::new("git") + .args(["-c", "commit.gpgSign=false"]) + .args(args) + .current_dir(source.path()) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .output()?; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + // Both checkouts succeed, but their names do not match the configured marketplaces. + let marketplace_names = ["team", "work"]; + for marketplace_name in marketplace_names { + record_user_marketplace( + app.config.codex_home.as_path(), + marketplace_name, + &MarketplaceConfigUpdate { + source_type: "git", + source: &source.path().display().to_string(), + ref_name: None, + sparse_paths: &[], + }, + )?; + } + app.config = ConfigBuilder::default() + .codex_home(app.config.codex_home.to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: Some(source.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + app.local_settings.tui.animations = false; + app.local_settings.tui.show_tooltips = false; + while events.try_recv().is_ok() {} + + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut warnings_received = 0; + while warnings_received < marketplace_names.len() { + let event = server.next_event().await.expect("app-server notification"); + let is_warning = matches!( + &event.event, + AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::Warning(_)) + ); + app.handle_backend_event(&server, event).await; + if is_warning { + warnings_received += 1; + } + } + + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + while let Ok(event) = events.try_recv() { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + let size = Size::new(/*width*/ 80, /*height*/ 16); + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + app.render_owned_transcript(&mut tui, size)?; + let notice = buffer_text(last_rendered_buffer(&tui.terminal)); + + Box::pin(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::F(2).into()))) + .await?; + app.render_owned_transcript(&mut tui, size)?; + let first_details = buffer_text(last_rendered_buffer(&tui.terminal)); + + Box::pin(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Right.into()))) + .await?; + app.render_owned_transcript(&mut tui, size)?; + let second_details = buffer_text(last_rendered_buffer(&tui.terminal)); + server.shutdown().await?; + + insta::assert_snapshot!( + "marketplace_upgrade_warning", + normalize_snapshot_paths(format!( + "Warning notice:\n{notice}\n\nFirst warning details:\n{first_details}\n\nSecond warning details:\n{second_details}" + )) + ); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__marketplace_upgrade_tests__marketplace_upgrade_warning.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__marketplace_upgrade_tests__marketplace_upgrade_warning.snap new file mode 100644 index 000000000000..1cb3812db04a --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__marketplace_upgrade_tests__marketplace_upgrade_warning.snap @@ -0,0 +1,57 @@ +--- +source: tui/src/app/tests/marketplace_upgrade_tests.rs +expression: "normalize_snapshot_paths(format!(\"Warning notice:\\n{notice}\\n\\nFirst warning details:\\n{first_details}\\n\\nSecond warning details:\\n{second_details}\"))" +--- +Warning notice: + + + + + + + + + + + + +› Ask Codex to do anything + + GPT-5.6-Sol default · /tmp/project + ? for shortcuts ⚠ 2 warnings · f2 to view + +First warning details: + + + + + Warnings · 1 of 2 · Startup + + Codex could not update marketplace `team`: upgraded marketplace name `other` + does not match configured marketplace `team` + + + + + + + + k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll + +Second warning details: + + + + + Warnings · 2 of 2 · Startup + + Codex could not update marketplace `work`: upgraded marketplace name `other` + does not match configured marketplace `work` + + + + + + + + k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll From c21153bd920a514809e58d9bd10326267ab2ca36 Mon Sep 17 00:00:00 2001 From: trpcic-openai Date: Fri, 23 Oct 2026 18:41:34 +0000 Subject: [PATCH 004/287] Track CUA tool call starts and deduplicate lifecycle events (#57167) ## What changed Emit `codex_cua_tool_call_started` for `js` MCP calls on Node REPL-backed connectors. Include start time, tool identity, plugin and connector IDs, and available thread and response attribution without recording tool arguments. Suppress duplicate starts and replayed completions for CUA calls while retaining the existing MCP completion event. A completion received without a start does not prevent a later valid lifecycle from being recorded. ## Testing Add a lifecycle regression test covering missing starts, duplicate notifications, replay after completion, attribution fields, timestamps, and exclusion of tool arguments from serialized events. GitOrigin-RevId: 68554ad76c636bb3c2be38bda56f5ea6da8d9830 --- codex-rs/analytics/src/events.rs | 31 +++ codex-rs/analytics/src/product_attribution.rs | 1 + codex-rs/analytics/src/reducer.rs | 182 +++++++++++++++--- .../analytics/src/tests/suite/tools_tests.rs | 94 +++++++++ 4 files changed, 281 insertions(+), 27 deletions(-) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index cb0aacff8dcc..79dbf285362b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -85,6 +85,7 @@ pub(crate) enum TrackEventRequest { CommandExecution(CodexCommandExecutionEventRequest), PluginMeasurement(CodexPluginMeasurementEventRequest), FileChange(CodexFileChangeEventRequest), + CuaToolCallStarted(CodexCuaToolCallStartedEventRequest), McpToolCall(CodexMcpToolCallEventRequest), DynamicToolCall(CodexDynamicToolCallEventRequest), ControlToolCall(CodexControlToolCallEventRequest), @@ -167,6 +168,7 @@ impl TrackEventRequest { match self { Self::PluginUsed(event) => event.event_params.plugin.plugin_id.is_some(), Self::SkillInvocation(event) => event.event_params.plugin_id.is_some(), + Self::CuaToolCallStarted(event) => event.event_params.plugin_id.is_some(), Self::McpToolCall(event) => event.event_params.plugin_id.is_some(), Self::ArtifactOperation(event) => !event.event_params.plugin_id.is_empty(), Self::PluginMeasurement(event) => !event.event_params.plugin_id.is_empty(), @@ -714,6 +716,35 @@ pub(crate) struct CodexToolItemEventBase { pub(crate) requested_network_access: bool, } +#[derive(Serialize)] +pub(crate) struct CodexCuaToolCallStartedEventParams { + pub(crate) thread_id: String, + pub(crate) session_id: String, + pub(crate) turn_id: String, + pub(crate) root_turn_id: Option, + pub(crate) item_id: String, + pub(crate) cell_id: Option, + pub(crate) parent_call_id: Option, + pub(crate) originating_response_id: Option, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_event_type: Option, + pub(crate) started_at_ms: u64, + pub(crate) mcp_server_name: String, + pub(crate) mcp_tool_name: String, + pub(crate) plugin_id: Option, + pub(crate) connector_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCuaToolCallStartedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCuaToolCallStartedEventParams, +} + #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/codex-rs/analytics/src/product_attribution.rs b/codex-rs/analytics/src/product_attribution.rs index 4fa872102e68..da5958f35d52 100644 --- a/codex-rs/analytics/src/product_attribution.rs +++ b/codex-rs/analytics/src/product_attribution.rs @@ -88,6 +88,7 @@ fn event_thread_id(event: &TrackEventRequest) -> Option<&str> { TrackEventRequest::CommandExecution(event) => Some(&event.event_params.base.thread_id), TrackEventRequest::PluginMeasurement(event) => Some(&event.event_params.thread_id), TrackEventRequest::FileChange(event) => Some(&event.event_params.base.thread_id), + TrackEventRequest::CuaToolCallStarted(event) => Some(&event.event_params.thread_id), TrackEventRequest::McpToolCall(event) => Some(&event.event_params.base.thread_id), TrackEventRequest::DynamicToolCall(event) => Some(&event.event_params.base.thread_id), TrackEventRequest::ControlToolCall(event) => Some(&event.event_params.base.thread_id), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 0bebe6771d3f..cef0a71216d1 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -14,6 +14,8 @@ use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexControlToolCallEventParams; use crate::events::CodexControlToolCallEventRequest; +use crate::events::CodexCuaToolCallStartedEventParams; +use crate::events::CodexCuaToolCallStartedEventRequest; use crate::events::CodexDynamicToolCallEventParams; use crate::events::CodexDynamicToolCallEventRequest; use crate::events::CodexFileChangeEventParams; @@ -165,6 +167,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::ModelInvocationContext; use codex_protocol::items::is_safe_plugin_relative_path; +use codex_protocol::mcp::is_node_repl_backed_connector; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; @@ -342,6 +345,16 @@ impl<'a> AnalyticsDropSite<'a> { } } + fn cua_tool_call_started(thread_id: &'a str, turn_id: &'a str, item_id: &'a str) -> Self { + Self { + event_name: "CUA tool call start", + thread_id, + turn_id: Some(turn_id), + review_id: None, + item_id: Some(item_id), + } + } + fn turn_steer(thread_id: &'a str) -> Self { Self { event_name: "turn steer", @@ -478,6 +491,7 @@ struct TurnState { latest_diff: Option, steer_count: usize, tool_counts: TurnToolCounts, + completed_cua_tool_item_ids: HashSet, resource_skill_invocations: HashSet, turn_event_emitted: bool, } @@ -2060,14 +2074,78 @@ impl AnalyticsReducer { self.turn_state(notification.turn_id.clone()) .thread_id .get_or_insert(notification.thread_id.clone()); + let key = ToolItemKey { + thread_id: notification.thread_id.clone(), + turn_id: notification.turn_id.clone(), + item_id: item_id.to_string(), + }; + let cua_identity = cua_mcp_tool_identity(¬ification.item); + if cua_identity.is_some() + && self + .turns + .get(¬ification.turn_id) + .is_some_and(|turn| turn.completed_cua_tool_item_ids.contains(item_id)) + { + return; + } + let first_notification = !self.tool_items_started_at_ms.contains_key(&key); self.tool_items_started_at_ms - .entry(ToolItemKey { - thread_id: notification.thread_id, - turn_id: notification.turn_id, - item_id: item_id.to_string(), - }) + .entry(key) .and_modify(|(timestamp, _)| *timestamp = started_at_ms) .or_insert((started_at_ms, model_context)); + if !first_notification { + return; + } + let Some((server, tool, plugin_id, connector_id)) = cua_identity else { + return; + }; + let Some((connection_state, thread_state, thread_metadata)) = self + .thread_context_or_warn(AnalyticsDropSite::cua_tool_call_started( + ¬ification.thread_id, + ¬ification.turn_id, + item_id, + )) + else { + return; + }; + let attribution = tool_response_attribution( + item_id, + /*cell_id*/ None, + self.tool_response_states + .get(&(notification.thread_id.clone(), notification.turn_id.clone())), + self.code_mode_cells.get(¬ification.thread_id), + ); + let root_turn_id = self + .turns + .get(¬ification.turn_id) + .and_then(|turn| turn.resolved_config.as_ref()) + .and_then(|config| config.turn_metadata.root_turn_id()); + out.push(TrackEventRequest::CuaToolCallStarted( + CodexCuaToolCallStartedEventRequest { + event_type: "codex_cua_tool_call_started", + event_params: CodexCuaToolCallStartedEventParams { + thread_id: notification.thread_id, + session_id: thread_metadata.session_id.clone(), + turn_id: notification.turn_id, + root_turn_id, + item_id: item_id.to_string(), + cell_id: attribution.cell_id, + parent_call_id: attribution.parent_call_id, + originating_response_id: attribution.originating_response_id, + app_server_client: thread_state.app_server_client(connection_state), + runtime: connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source.clone(), + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + tool_event_type: attribution.tool_event_type, + started_at_ms, + mcp_server_name: server.to_string(), + mcp_tool_name: tool.to_string(), + plugin_id: plugin_id.map(str::to_string), + connector_id: connector_id.map(str::to_string), + }, + }, + )); } ServerNotification::ItemCompleted(notification) => { if matches!(notification.item, ThreadItem::SubAgentActivity { .. }) { @@ -2090,6 +2168,15 @@ impl AnalyticsReducer { turn_id: notification.turn_id.clone(), item_id: item_id.to_string(), }; + let is_cua = cua_mcp_tool_identity(¬ification.item).is_some(); + if is_cua + && self + .turns + .get(¬ification.turn_id) + .is_some_and(|turn| turn.completed_cua_tool_item_ids.contains(item_id)) + { + return; + } let elicitation_type = if matches!(notification.item, ThreadItem::McpToolCall { .. }) { // Producers enqueue known classifications before completion on @@ -2172,6 +2259,9 @@ impl AnalyticsReducer { emission, out, ); + if is_cua && let Some(turn) = self.turns.get_mut(¬ification.turn_id) { + turn.completed_cua_tool_item_ids.insert(item_id.to_string()); + } } self.item_review_summaries.remove(&key); if self @@ -2837,6 +2927,28 @@ pub(crate) fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { } } +fn cua_mcp_tool_identity(item: &ThreadItem) -> Option<(&str, &str, Option<&str>, Option<&str>)> { + let ThreadItem::McpToolCall { + server, + tool, + plugin_id, + app_context, + .. + } = item + else { + return None; + }; + let connector_id = app_context + .as_ref() + .map(|app_context| app_context.connector_id.as_str()); + (tool == "js" && is_node_repl_backed_connector(server, connector_id)).then_some(( + server, + tool, + plugin_id.as_deref(), + connector_id, + )) +} + fn tool_event_base_mut(event: &mut TrackEventRequest) -> Option<&mut CodexToolItemEventBase> { match event { TrackEventRequest::CommandExecution(event) => Some(&mut event.event_params.base), @@ -2859,36 +2971,52 @@ fn enrich_tool_response_event( let Some(base) = tool_event_base_mut(event) else { return; }; + let attribution = + tool_response_attribution(&base.item_id, base.cell_id.clone(), Some(state), cells); + base.tool_event_type = attribution.tool_event_type; + base.cell_id = attribution.cell_id; + base.parent_call_id = attribution.parent_call_id; + base.originating_response_id = attribution.originating_response_id; +} + +#[derive(Default)] +struct ToolResponseAttribution { + tool_event_type: Option, + cell_id: Option, + parent_call_id: Option, + originating_response_id: Option, +} + +fn tool_response_attribution( + item_id: &str, + cell_id: Option, + state: Option<&ToolResponseState>, + cells: Option<&HashMap>, +) -> ToolResponseAttribution { // A cell association can also describe a separately sampled wait call. Classify // only from evidence about this exact call ID, not its parent or response lineage. - base.tool_event_type = match ( - state.response_ids_by_call_id.contains_key(&base.item_id), - state.cell_ids_by_child_call_id.contains_key(&base.item_id), + let tool_event_type = match ( + state.is_some_and(|state| state.response_ids_by_call_id.contains_key(item_id)), + state.is_some_and(|state| state.cell_ids_by_child_call_id.contains_key(item_id)), ) { (true, false) => Some(ToolEventType::ModelToolCall), (false, true) => Some(ToolEventType::InnerToolCall), (false, false) | (true, true) => None, }; - if base.cell_id.is_none() { - base.cell_id = state.cell_ids_by_child_call_id.get(&base.item_id).cloned(); - } - - let cell = base - .cell_id - .as_ref() - .and_then(|cell_id| cells?.get(cell_id)); - if let Some(cell) = cell { - base.parent_call_id = - (cell.parent_call_id != base.item_id).then(|| cell.parent_call_id.clone()); - } else { - base.cell_id = None; - base.parent_call_id = None; - } - base.originating_response_id = state - .response_ids_by_call_id - .get(&base.item_id) - .cloned() + let cell_id = cell_id + .or_else(|| state.and_then(|state| state.cell_ids_by_child_call_id.get(item_id).cloned())); + let cell = cell_id.as_ref().and_then(|cell_id| cells?.get(cell_id)); + let parent_call_id = + cell.and_then(|cell| (cell.parent_call_id != item_id).then(|| cell.parent_call_id.clone())); + let originating_response_id = state + .and_then(|state| state.response_ids_by_call_id.get(item_id).cloned()) .or_else(|| cell.and_then(|cell| cell.originating_response_id.clone())); + ToolResponseAttribution { + tool_event_type, + cell_id: cell.is_some().then_some(cell_id).flatten(), + parent_call_id, + originating_response_id, + } } fn item_review_summary_key(pending_review: &PendingReviewState) -> Option { diff --git a/codex-rs/analytics/src/tests/suite/tools_tests.rs b/codex-rs/analytics/src/tests/suite/tools_tests.rs index 6272b8458fe3..41e94976960c 100644 --- a/codex-rs/analytics/src/tests/suite/tools_tests.rs +++ b/codex-rs/analytics/src/tests/suite/tools_tests.rs @@ -218,6 +218,100 @@ fn command_execution_event_serializes_expected_shape() { assert_eq!(payload, expected); } +#[tokio::test] +async fn cua_tool_call_lifecycle_requires_a_start_and_deduplicates_replay() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + ingest_turn_prerequisites( + &mut reducer, + &mut events, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + events.clear(); + + let item = |status| ThreadItem::McpToolCall { + id: "cua-call".into(), + server: "codex_apps".into(), + tool: "js".into(), + status, + arguments: json!({"script": "synthetic-private-input"}), + app_context: Some(McpToolCallAppContext { + connector_id: "connector_openai_browser".into(), + link_id: None, + resource_uri: None, + app_name: None, + action_name: None, + }), + mcp_app_resource_uri: None, + mcp_app_ui: None, + plugin_id: Some("browser@openai-curated".into()), + read_only_hint: None, + result: None, + error: None, + duration_ms: None, + }; + let started = ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: "thread-2".into(), + turn_id: "turn-2".into(), + started_at_ms: 1_000, + item: item(McpToolCallStatus::InProgress), + }); + let completed = ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread-2".into(), + turn_id: "turn-2".into(), + completed_at_ms: 1_100, + item: item(McpToolCallStatus::Completed), + }); + for notification in [ + completed.clone(), // Missing start must not poison later deduplication. + started.clone(), + started.clone(), + completed.clone(), + started, // Replay after a valid terminal. + completed, + ] { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(notification)), + &mut events, + ) + .await; + } + + let [ + TrackEventRequest::CuaToolCallStarted(start), + TrackEventRequest::McpToolCall(terminal), + ] = events.as_slice() + else { + panic!("expected exactly one CUA start and one terminal"); + }; + assert_eq!( + ( + start.event_type, + start.event_params.connector_id.as_deref(), + start.event_params.plugin_id.as_deref(), + start.event_params.started_at_ms, + terminal.event_params.base.started_at_ms, + ), + ( + "codex_cua_tool_call_started", + Some("connector_openai_browser"), + Some("browser@openai-curated"), + 1_000, + 1_000, + ) + ); + assert!( + !serde_json::to_string(&events) + .expect("serialize CUA lifecycle events") + .contains("synthetic-private-input") + ); +} + #[tokio::test] async fn item_lifecycle_notifications_publish_command_execution_event() { let mut reducer = AnalyticsReducer::default(); From f627672c4988fdb9adcdf026facd14e341550229 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 23 Oct 2026 18:45:24 +0000 Subject: [PATCH 005/287] Centralize Node.js version selection on 24.22.1 (#57168) ## What changed - Add `.nvmrc` pinning Node.js to `24.22.1` and raise the root package's minimum Node.js version to match. - Use `.nvmrc` in repository checks, SDK CI, and npm packaging and publishing workflows. - Update MCP conformance instructions to use the repository's pinned Node.js version. GitOrigin-RevId: d864875e538de2b99df73e5a03fc870ce0f90cf0 --- .github/workflows/repo-checks.yml | 2 +- .github/workflows/rust-release.yml | 6 +++--- .github/workflows/sdk.yml | 2 +- .nvmrc | 1 + package.json | 2 +- scripts/mcp_conformance/README.md | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 163e5b0448c3..8938ea62e8ae 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -57,7 +57,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 22 + node-version-file: .nvmrc - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index d6a5f6aaca55..d78cbbbf81f3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1579,7 +1579,7 @@ jobs: - name: Setup Node.js for npm packaging uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 22 + node-version-file: .nvmrc - name: Install dependencies run: pnpm install --frozen-lockfile @@ -1893,8 +1893,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. - node-version: 24 + # Trusted publishing requires the selected Node.js release to include npm >= 11.5.1. + node-version-file: .nvmrc registry-url: "https://registry.npmjs.org" scope: "@openai" diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 4cb081eb94f8..8a764dbf0303 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -64,7 +64,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 22 + node-version-file: .nvmrc cache: pnpm - name: Prepare Bazel CI diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000000..ce62013c39ae --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.22.1 diff --git a/package.json b/package.json index 1a3194ed233c..525cd2009a99 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "punycode": "^2.3.1" }, "engines": { - "node": ">=22", + "node": ">=24.22.1", "pnpm": ">=10.34.5" }, "packageManager": "pnpm@10.34.5+sha512.a4ee05f2f73658255bd6a89859c065a45c28a57daefae2c893a168ee2b73168c37b91e83e57ea67654ad03f03031746430e8bce38e362e042605fb8abc80192e" diff --git a/scripts/mcp_conformance/README.md b/scripts/mcp_conformance/README.md index d75aa1f3b896..03ff5849bfbc 100644 --- a/scripts/mcp_conformance/README.md +++ b/scripts/mcp_conformance/README.md @@ -7,7 +7,7 @@ stdio, OAuth, and additional transport and security regression fixtures. The official upstream suite is pinned to `modelcontextprotocol/conformance@49103de6ed70804e940637bf3e9e29e4a3f54e64`. -Use Node.js 22 and Python 3.10 or later. +Use the Node.js version in the repository's `.nvmrc` and Python 3.10 or later. ## Run the conformance gate From 6e319d2ca7071c1b5d06d7f9cf82c26c25417913 Mon Sep 17 00:00:00 2001 From: smurching-oai Date: Fri, 23 Oct 2026 18:56:12 +0000 Subject: [PATCH 006/287] Clarify config loader APIs and share interactive login setup (#57169) ## What changed - Rename ChatGPT-specific bundle loaders to `chatgpt_config_bundle_loader*` and use managed-config terminology for shared loader plumbing. - Pass `Config` directly when updating the app-server's managed configuration loader. - Share configuration loading, logging setup, and result handling between browser and device-code login. - Expose the CLI cloud-config helpers and `provider_uses_first_party_auth_path` through their library APIs. ## Testing Extend CLI login coverage to verify that `forced_login_method="api"` rejects both `login` and `login --device-auth` while preserving saved API-key credentials. GitOrigin-RevId: 9e8fa6110dc80882ba365e550b48ac77da604940 --- .../app-server/src/application_network.rs | 2 +- codex-rs/app-server/src/config_manager.rs | 23 ++-- .../src/config_manager_provider_tests.rs | 2 +- .../app-server/src/in_process_bootstrap.rs | 12 +- codex-rs/app-server/src/lib.rs | 8 +- .../request_processors/account_processor.rs | 26 ++-- codex-rs/app-server/src/startup_config.rs | 8 +- codex-rs/cli/src/cloud_config.rs | 8 +- .../cli/src/debug_sandbox/cloud_config.rs | 4 +- codex-rs/cli/src/exec_server_command.rs | 4 +- codex-rs/cli/src/lib.rs | 1 + codex-rs/cli/src/login.rs | 130 ++++++++++-------- codex-rs/cli/src/main.rs | 2 +- codex-rs/cli/tests/login.rs | 10 ++ codex-rs/cloud-config/src/bundle_loader.rs | 24 ++-- codex-rs/cloud-config/src/config_server.rs | 2 +- .../cloud-config/src/config_server_tests.rs | 4 +- codex-rs/cloud-config/src/lib.rs | 6 +- codex-rs/cloud-config/src/service_tests.rs | 12 +- codex-rs/exec/src/lib.rs | 6 +- codex-rs/model-provider/src/lib.rs | 1 + codex-rs/model-provider/src/provider.rs | 2 +- codex-rs/tui/src/lib.rs | 6 +- codex-rs/tui/src/onboarding/auth.rs | 4 +- codex-rs/tui/src/startup_orchestration.rs | 2 +- .../src/machine_policy.rs | 4 +- 26 files changed, 152 insertions(+), 161 deletions(-) diff --git a/codex-rs/app-server/src/application_network.rs b/codex-rs/app-server/src/application_network.rs index bb5d0987d87c..1a3223eff5a0 100644 --- a/codex-rs/app-server/src/application_network.rs +++ b/codex-rs/app-server/src/application_network.rs @@ -75,7 +75,7 @@ impl ConfigManager { .map_err(io::Error::other)?; let revision = self.network_policy.policy().revision(); let local_sources = self.load_local_network_policy(revision).await?; - let cloud_config = self.current_cloud_config_bundle(); + let cloud_config = self.current_managed_config_loader(); let result = async { let cloud_snapshot = cloud_config.get_snapshot().await; let cloud = cloud_snapshot.bundle.clone().map_err(io::Error::other)?; diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index fd37f03b51b8..658f7f52bc56 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -1,5 +1,5 @@ use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_config_bundle_loader; +use codex_cloud_config::chatgpt_config_bundle_loader; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStack; use codex_config::LoaderOverrides; @@ -174,7 +174,7 @@ impl ConfigManager { .unwrap_or_default() } - pub(crate) fn current_cloud_config_bundle(&self) -> CloudConfigBundleLoader { + pub(crate) fn current_managed_config_loader(&self) -> CloudConfigBundleLoader { self.cloud_config_bundle .read() .map(|guard| guard.clone()) @@ -191,12 +191,13 @@ impl ConfigManager { Ok(()) } - pub(crate) fn replace_cloud_config_bundle_loader( + pub(crate) fn update_managed_config_loader( &self, auth_manager: Arc, - chatgpt_base_url: String, - http_client_factory: codex_http_client::HttpClientFactory, + config: &Config, ) { + let chatgpt_base_url = config.chatgpt_base_url.clone(); + let http_client_factory = config.http_client_factory(); let endpoint = codex_backend_client::Client::new( chatgpt_base_url.clone(), http_client_factory.clone(), @@ -207,7 +208,7 @@ impl ConfigManager { .policy() .restrict_to_endpoints(endpoint.parse().into_iter().collect()), ); - let loader = cloud_config_bundle_loader( + let loader = chatgpt_config_bundle_loader( auth_manager, chatgpt_base_url, self.codex_home.clone(), @@ -222,7 +223,7 @@ impl ConfigManager { self.revoke_mcp_ema(); } - pub(crate) fn clear_cloud_config_bundle_loader(&self) { + pub(crate) fn clear_chatgpt_config_loader(&self) { if let Ok(mut guard) = self.cloud_config_bundle.write() { guard.retire_ema_policy(); *guard = CloudConfigBundleLoader::default(); @@ -409,7 +410,7 @@ impl ConfigManager { /// Load local policy before auth, then fetch the account's cloud configuration. /// Callers choose their error/recovery policy before initializing any runtimes. - pub(crate) async fn load_cloud_startup_config( + pub(crate) async fn load_managed_startup_config( &self, cwd: Option, ) -> std::io::Result { @@ -418,11 +419,7 @@ impl ConfigManager { AuthManager::shared_from_config(&bootstrap, /*enable_codex_api_key_env*/ false) .await .map_err(std::io::Error::other)?; - self.replace_cloud_config_bundle_loader( - auth, - bootstrap.chatgpt_base_url.clone(), - bootstrap.http_client_factory(), - ); + self.update_managed_config_loader(auth, &bootstrap); let config = self.load_latest_config(cwd).await; Ok(CloudStartupConfig { bootstrap, config }) } diff --git a/codex-rs/app-server/src/config_manager_provider_tests.rs b/codex-rs/app-server/src/config_manager_provider_tests.rs index b05521f5c0b7..abd14e221030 100644 --- a/codex-rs/app-server/src/config_manager_provider_tests.rs +++ b/codex-rs/app-server/src/config_manager_provider_tests.rs @@ -236,7 +236,7 @@ async fn catalog_reload_uses_latest_model_provider() -> Result<()> { ); let startup = manager.load_non_project_config().await?; assert_eq!(startup.model_provider_id, "gateway"); - manager.clear_cloud_config_bundle_loader(); + manager.clear_chatgpt_config_loader(); let refreshed = manager .load_startup_config(/*fallback_cwd*/ None) .await? diff --git a/codex-rs/app-server/src/in_process_bootstrap.rs b/codex-rs/app-server/src/in_process_bootstrap.rs index a41115c98dfb..ece3737a5690 100644 --- a/codex-rs/app-server/src/in_process_bootstrap.rs +++ b/codex-rs/app-server/src/in_process_bootstrap.rs @@ -126,11 +126,7 @@ pub(super) async fn configure( ) .await .map_err(IoError::other)?; - config_manager.replace_cloud_config_bundle_loader( - bootstrap_auth_manager, - bootstrap_config.chatgpt_base_url.clone(), - bootstrap_config.http_client_factory(), - ); + config_manager.update_managed_config_loader(bootstrap_auth_manager, &bootstrap_config); let policy_config = config_manager .load_startup_config(Some(config.cwd.to_path_buf())) .await? @@ -168,11 +164,7 @@ pub(super) async fn configure( config.application_auth_route_config = policy_config.application_auth_route_config; config.forced_login_method = policy_config.forced_login_method; config.forced_chatgpt_workspace_id = policy_config.forced_chatgpt_workspace_id; - config_manager.replace_cloud_config_bundle_loader( - policy_auth_manager, - config.chatgpt_base_url.clone(), - config.http_client_factory(), - ); + config_manager.update_managed_config_loader(policy_auth_manager, config); codex_login::default_client::set_default_client_residency_requirement( config.enforce_residency.value(), ); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8abfa4aa5dce..819048a63cc1 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -569,7 +569,7 @@ pub async fn run_main_with_transport_options( Arc::new(NoopThreadConfigLoader), ); let startup = config_manager - .load_cloud_startup_config(startup_cwd.clone()) + .load_managed_startup_config(startup_cwd.clone()) .await?; let mut config_warnings = Vec::new(); let mut plugin_startup_config = PluginStartupConfig::Current; @@ -600,11 +600,7 @@ pub async fn run_main_with_transport_options( AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false) .await .map_err(std::io::Error::other)?; - config_manager.replace_cloud_config_bundle_loader( - auth_manager.clone(), - config.chatgpt_base_url.clone(), - config.http_client_factory(), - ); + config_manager.update_managed_config_loader(auth_manager.clone(), &config); config_manager .sync_default_client_residency_requirement() .await; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 0aa43403f128..90b5ef4af9db 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -476,7 +476,7 @@ impl AccountRequestProcessor { ) { Ok(()) => { self.auth_manager.reload().await; - self.config_manager.clear_cloud_config_bundle_loader(); + self.config_manager.clear_chatgpt_config_loader(); Ok(()) } Err(err) => Err(internal_error(format!("failed to save api key: {err}"))), @@ -575,7 +575,7 @@ impl AccountRequestProcessor { } .map_err(|err| internal_error(format!("failed to save Amazon Bedrock auth: {err}")))?; self.auth_manager.reload().await; - self.config_manager.clear_cloud_config_bundle_loader(); + self.config_manager.clear_chatgpt_config_loader(); Ok(LoginAccountResponse::AmazonBedrock {}) } .await; @@ -910,11 +910,8 @@ impl AccountRequestProcessor { ))) .await .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; - self.config_manager.replace_cloud_config_bundle_loader( - self.auth_manager.clone(), - self.config.chatgpt_base_url.clone(), - self.config.http_client_factory(), - ); + self.config_manager + .update_managed_config_loader(self.auth_manager.clone(), &self.config); self.config_manager .sync_default_client_residency_requirement() .await; @@ -986,11 +983,8 @@ impl AccountRequestProcessor { let auth_changes = self.auth_manager.auth_change_state_receiver(); let owner_generation = auth_changes.borrow().owner_generation; self.thread_manager.invalidate_mcp_runtimes().await; - self.config_manager.replace_cloud_config_bundle_loader( - self.auth_manager.clone(), - self.config.chatgpt_base_url.clone(), - self.config.http_client_factory(), - ); + self.config_manager + .update_managed_config_loader(self.auth_manager.clone(), &self.config); self.config_manager .sync_default_client_residency_requirement() .await; @@ -1542,11 +1536,7 @@ pub(crate) async fn logout_configured_account( // Another process may have changed the persisted workspace. Reload both // account authority and its policy before selecting a grant to remove. auth_manager.reload().await; - config_manager.replace_cloud_config_bundle_loader( - Arc::clone(auth_manager), - fallback_config.chatgpt_base_url.clone(), - fallback_config.http_client_factory(), - ); + config_manager.update_managed_config_loader(Arc::clone(auth_manager), fallback_config); let config = config_manager .load_latest_config(/*fallback_cwd*/ None) .await; @@ -1602,7 +1592,7 @@ pub(crate) async fn clear_account_configuration( config_manager: &ConfigManager, config: &Config, ) -> Result<(), JSONRPCErrorError> { - config_manager.clear_cloud_config_bundle_loader(); + config_manager.clear_chatgpt_config_loader(); if config.model_provider.is_amazon_bedrock() { clear_user_model_provider_if_bedrock(config_manager, config).await?; } diff --git a/codex-rs/app-server/src/startup_config.rs b/codex-rs/app-server/src/startup_config.rs index d265266f7c35..71dc648265e8 100644 --- a/codex-rs/app-server/src/startup_config.rs +++ b/codex-rs/app-server/src/startup_config.rs @@ -120,7 +120,7 @@ async fn read_startup_config( ); let cwd = std::env::current_dir().ok(); let loaded = manager - .load_cloud_startup_config(cwd.clone()) + .load_managed_startup_config(cwd.clone()) .await .map_err(startup_error)?; let config = match loaded.config { @@ -143,11 +143,7 @@ async fn read_startup_config( set_default_client_residency_requirement(config.enforce_residency.value()); let config = logout_configured_account(&manager, &auth, &config).await?; clear_account_configuration(&manager, &config).await?; - manager.replace_cloud_config_bundle_loader( - Arc::clone(&auth), - config.chatgpt_base_url.clone(), - config.http_client_factory(), - ); + manager.update_managed_config_loader(Arc::clone(&auth), &config); manager .load_latest_config(cwd) .await diff --git a/codex-rs/cli/src/cloud_config.rs b/codex-rs/cli/src/cloud_config.rs index 9dd74aae8d5b..c67b34d62194 100644 --- a/codex-rs/cli/src/cloud_config.rs +++ b/codex-rs/cli/src/cloud_config.rs @@ -1,7 +1,7 @@ use anyhow::Context; use anyhow::Result; use codex_app_server::in_process::EmbeddedNetworkPolicy; -use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_core::config::Config; @@ -14,7 +14,7 @@ use codex_core::config::load_config_toml_with_layer_stack; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; -pub(crate) async fn load_config( +pub async fn load_config( config_overrides: &CliConfigOverrides, loader_overrides: LoaderOverrides, ) -> Result { @@ -31,7 +31,7 @@ pub(crate) async fn load_config( Ok(config) } -pub(crate) async fn config_builder( +pub async fn config_builder( config_overrides: &CliConfigOverrides, loader_overrides: LoaderOverrides, harness_overrides: ConfigOverrides, @@ -58,7 +58,7 @@ pub(crate) async fn config_builder( ) .await .context("failed to load bootstrap configuration")?; - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( + let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( network_policy.bind_bootstrap_auth( bootstrap_auth_config(codex_home.as_path(), &bootstrap_config) .context("failed to resolve cloud configuration authentication")?, diff --git a/codex-rs/cli/src/debug_sandbox/cloud_config.rs b/codex-rs/cli/src/debug_sandbox/cloud_config.rs index 952e6d9497e7..970af3fb6ea6 100644 --- a/codex-rs/cli/src/debug_sandbox/cloud_config.rs +++ b/codex-rs/cli/src/debug_sandbox/cloud_config.rs @@ -1,4 +1,4 @@ -use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_core::config::bootstrap_auth_config; @@ -40,7 +40,7 @@ pub(super) async fn bootstrap_cloud_config_bundle( }, ) .await?; - Ok(cloud_config_bundle_loader_for_storage( + Ok(chatgpt_config_bundle_loader_for_storage( bootstrap_auth_config(codex_home.as_path(), &bootstrap_config)?, /*enable_codex_api_key_env*/ false, ) diff --git a/codex-rs/cli/src/exec_server_command.rs b/codex-rs/cli/src/exec_server_command.rs index ffb08f5312c6..ab8ab858e615 100644 --- a/codex-rs/cli/src/exec_server_command.rs +++ b/codex-rs/cli/src/exec_server_command.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use clap::Parser; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -506,7 +506,7 @@ async fn load_exec_server_config( ) .await?; let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?; - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( + let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( bootstrap_auth_config, /*enable_codex_api_key_env*/ false, ) diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index f35544276be3..f0d447bb295b 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cloud_config; pub(crate) mod debug_sandbox; mod exit_status; pub(crate) mod login; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index b6836c33ef30..1e194f67cc07 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -172,29 +172,8 @@ pub async fn login_with_chatgpt( server.block_until_done().await } -pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { - let config = load_config_or_exit(cli_config_overrides).await; - let _login_log_guard = init_login_file_logging(&config); - tracing::info!("starting browser login flow"); - - if !config - .auth_config() - .is_login_method_allowed(ForcedLoginMethod::Chatgpt) - { - eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); - std::process::exit(1); - } - - let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); - match login_with_chatgpt( - config.codex_home.to_path_buf(), - effective_chatgpt_workspaces, - config.cli_auth_credentials_store_mode, - config.auth_keyring_backend_kind(), - config.auth_route_config(), - ) - .await - { +fn finish_login(result: std::io::Result<()>) -> ! { + match result { Ok(_) => { eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); @@ -206,6 +185,43 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } } +async fn run_interactive_login( + cli_config_overrides: CliConfigOverrides, + login: impl FnOnce(Config) -> F, +) -> ! +where + F: std::future::Future>, +{ + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + finish_login(login(config).await) +} + +pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { + run_interactive_login(cli_config_overrides, |config| async move { + tracing::info!("starting browser login flow"); + + if !config + .auth_config() + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + + let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); + login_with_chatgpt( + config.codex_home.to_path_buf(), + effective_chatgpt_workspaces, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + config.auth_route_config(), + ) + .await + }) + .await +} + pub async fn run_login_with_api_key( cli_config_overrides: CliConfigOverrides, api_key: String, @@ -326,46 +342,38 @@ pub async fn run_login_with_device_code( issuer_base_url: Option, client_id: Option, ) -> ! { - let config = load_config_or_exit(cli_config_overrides).await; - let _login_log_guard = init_login_file_logging(&config); - tracing::info!("starting device code login flow"); - if !config - .auth_config() - .is_login_method_allowed(ForcedLoginMethod::Chatgpt) - { - eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); - std::process::exit(1); - } - let auth_route_config = config.auth_route_config(); - clear_existing_auth_before_login( - &config.codex_home, - config.cli_auth_credentials_store_mode, - config.auth_keyring_backend_kind(), - &auth_route_config, - ) - .await; - let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); - let mut opts = ServerOptions::new( - config.codex_home.to_path_buf(), - client_id.unwrap_or(CLIENT_ID.to_string()), - effective_chatgpt_workspaces, - config.cli_auth_credentials_store_mode, - config.auth_keyring_backend_kind(), - auth_route_config, - ); - if let Some(iss) = issuer_base_url { - opts.issuer = iss; - } - match run_device_code_login(opts).await { - Ok(()) => { - eprintln!("{LOGIN_SUCCESS_MESSAGE}"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error logging in with device code: {e}"); + run_interactive_login(cli_config_overrides, |config| async move { + tracing::info!("starting device code login flow"); + if !config + .auth_config() + .is_login_method_allowed(ForcedLoginMethod::Chatgpt) + { + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } - } + let auth_route_config = config.auth_route_config(); + clear_existing_auth_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + &auth_route_config, + ) + .await; + let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces(); + let mut opts = ServerOptions::new( + config.codex_home.to_path_buf(), + client_id.unwrap_or(CLIENT_ID.to_string()), + effective_chatgpt_workspaces, + config.cli_auth_credentials_store_mode, + config.auth_keyring_backend_kind(), + auth_route_config, + ); + if let Some(iss) = issuer_base_url { + opts.issuer = iss; + } + run_device_code_login(opts).await + }) + .await } /// Prefers device-code login (with `open_browser = false`) when headless environment is detected, but keeps diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f87786eafb0b..f8b752f8db53 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,6 +10,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; +use codex_cli::cloud_config; use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; @@ -52,7 +53,6 @@ static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; #[cfg(any(target_os = "macos", target_os = "windows"))] mod app_cmd; -mod cloud_config; mod config_override_args; mod daemon_install; mod daemon_telemetry; diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index 476cf8613fe8..b5e729f4ac1b 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -84,6 +84,16 @@ fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { Authentication is checked when you send a request. "); + for args in [vec!["login"], vec!["login", "--device-auth"]] { + codex_command(codex_home.path())? + .args(["-c", "forced_login_method=\"api\""]) + .args(args) + .assert() + .failure() + .stderr(contains("ChatGPT login is disabled")); + assert_eq!(read_auth_json(codex_home.path())?, auth); + } + Ok(()) } diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 997ae7d6225d..0ca022897b95 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -45,7 +45,7 @@ pub fn managed_config_bundle_loader( .network_policy() .clone() .restrict_to_endpoints(endpoint.parse().into_iter().collect()); - cloud_config_bundle_loader( + chatgpt_config_bundle_loader( auth_manager, chatgpt_base_url, codex_home, @@ -68,8 +68,8 @@ pub async fn managed_config_bundle_loader_for_storage( )); } let service = - cloud_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env).await?; - Ok(cloud_config_bundle_loader_for_service(service).0) + chatgpt_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env).await?; + Ok(managed_config_bundle_loader_for_service(service).0) } struct CloudConfigBundleLoaderLifetime { @@ -83,7 +83,7 @@ impl Drop for CloudConfigBundleLoaderLifetime { } } -pub fn cloud_config_bundle_loader( +pub fn chatgpt_config_bundle_loader( auth_manager: Arc, chatgpt_base_url: String, codex_home: PathBuf, @@ -98,11 +98,11 @@ pub fn cloud_config_bundle_loader( codex_home, CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - let (loader, _) = cloud_config_bundle_loader_for_service(service); + let (loader, _) = managed_config_bundle_loader_for_service(service); loader } -pub(crate) fn cloud_config_bundle_loader_for_service( +pub(crate) fn managed_config_bundle_loader_for_service( service: CloudConfigBundleService, ) -> (CloudConfigBundleLoader, JoinHandle<()>) where @@ -133,24 +133,24 @@ where (loader, refresh_task) } -pub async fn cloud_config_bundle_loader_for_storage( +pub async fn chatgpt_config_bundle_loader_for_storage( auth_config: AuthConfig, enable_codex_api_key_env: bool, ) -> std::io::Result { let service = - cloud_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env).await?; - let (loader, _) = cloud_config_bundle_loader_for_service(service); + chatgpt_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env).await?; + let (loader, _) = managed_config_bundle_loader_for_service(service); Ok(loader) } /// Fetches directly from the network on each load, without reading or writing /// the disk cache or starting a background refresher. -pub async fn cloud_config_bundle_loader_for_storage_without_cache( +pub async fn chatgpt_config_bundle_loader_for_storage_without_cache( auth_config: AuthConfig, enable_codex_api_key_env: bool, ) -> std::io::Result { let service = Arc::new( - cloud_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env) + chatgpt_config_bundle_service_for_storage(auth_config, enable_codex_api_key_env) .await? .without_cache(), ); @@ -160,7 +160,7 @@ pub async fn cloud_config_bundle_loader_for_storage_without_cache( })) } -async fn cloud_config_bundle_service_for_storage( +async fn chatgpt_config_bundle_service_for_storage( auth_config: AuthConfig, enable_codex_api_key_env: bool, ) -> std::io::Result>> { diff --git a/codex-rs/cloud-config/src/config_server.rs b/codex-rs/cloud-config/src/config_server.rs index 8a2422e9d5ed..4342273d2a05 100644 --- a/codex-rs/cloud-config/src/config_server.rs +++ b/codex-rs/cloud-config/src/config_server.rs @@ -37,7 +37,7 @@ pub fn config_server_bundle_loader( codex_home, CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - crate::bundle_loader::cloud_config_bundle_loader_for_service(service).0 + crate::bundle_loader::managed_config_bundle_loader_for_service(service).0 } Err(error) => CloudConfigBundleLoader::new(std::future::ready(Err(error))), } diff --git a/codex-rs/cloud-config/src/config_server_tests.rs b/codex-rs/cloud-config/src/config_server_tests.rs index db987b266fb3..d07566d2862a 100644 --- a/codex-rs/cloud-config/src/config_server_tests.rs +++ b/codex-rs/cloud-config/src/config_server_tests.rs @@ -1,7 +1,7 @@ #![cfg(test)] use super::*; -use crate::bundle_loader::cloud_config_bundle_loader_for_service; +use crate::bundle_loader::managed_config_bundle_loader_for_service; use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT; use crate::service::CloudConfigBundleService; use codex_config::CloudConfigBundleLoader; @@ -27,7 +27,7 @@ fn remote_loader(url: String, codex_home: PathBuf) -> CloudConfigBundleLoader { .http_client_factory() .clone(); let source = ConfigServerSource::new(url, /*oauth*/ None, codex_home.clone(), factory).unwrap(); - cloud_config_bundle_loader_for_service(CloudConfigBundleService::from_source( + managed_config_bundle_loader_for_service(CloudConfigBundleService::from_source( source, codex_home, CLOUD_CONFIG_BUNDLE_TIMEOUT, diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 0ede538cbdf5..41c72cbb8b82 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -13,9 +13,9 @@ mod metrics; mod service; mod validation; -pub use bundle_loader::cloud_config_bundle_loader; -pub use bundle_loader::cloud_config_bundle_loader_for_storage; -pub use bundle_loader::cloud_config_bundle_loader_for_storage_without_cache; +pub use bundle_loader::chatgpt_config_bundle_loader; +pub use bundle_loader::chatgpt_config_bundle_loader_for_storage; +pub use bundle_loader::chatgpt_config_bundle_loader_for_storage_without_cache; pub use bundle_loader::managed_config_auth_manager; pub use bundle_loader::managed_config_bundle_loader; pub use bundle_loader::managed_config_bundle_loader_for_storage; diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 51a10ec17a01..70fd0a02ea49 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -1347,7 +1347,7 @@ async fn production_loader_recovers_startup_timeouts_in_background() { codex_home.path().to_path_buf(), CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service); + let (loader, _) = crate::bundle_loader::managed_config_bundle_loader_for_service(service); let (initial, ()) = tokio::join!(loader.get(), async { fetcher.request_started.notified().await; tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await; @@ -1427,7 +1427,7 @@ async fn production_loader_restores_normal_refresh_interval_after_non_timeout_er codex_home.path().to_path_buf(), CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service); + let (loader, _) = crate::bundle_loader::managed_config_bundle_loader_for_service(service); let (initial, ()) = tokio::join!(loader.get(), async { fetcher.request_started.notified().await; tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await; @@ -1499,7 +1499,7 @@ async fn production_loader_refreshes_later_configs_and_preserves_failed_refreshe codex_home.path().to_path_buf(), CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service); + let (loader, _) = crate::bundle_loader::managed_config_bundle_loader_for_service(service); let (first, second) = tokio::join!(loader.get(), loader.get()); assert_eq!(first, Ok(Some(initial_bundle.clone()))); @@ -1556,7 +1556,7 @@ async fn each_loader_owns_refresh_until_its_last_clone_is_dropped() { CLOUD_CONFIG_BUNDLE_TIMEOUT, ); let (loader, original_task) = - crate::bundle_loader::cloud_config_bundle_loader_for_service(service); + crate::bundle_loader::managed_config_bundle_loader_for_service(service); let cloned_loader = loader.clone(); assert_eq!(loader.get().await, Ok(Some(test_bundle()))); fetcher.request_started.notified().await; @@ -1582,7 +1582,7 @@ async fn each_loader_owns_refresh_until_its_last_clone_is_dropped() { CLOUD_CONFIG_BUNDLE_TIMEOUT, ); let (replacement_loader, replacement_task) = - crate::bundle_loader::cloud_config_bundle_loader_for_service(replacement_service); + crate::bundle_loader::managed_config_bundle_loader_for_service(replacement_service); assert_eq!(replacement_loader.get().await, Ok(Some(test_bundle()))); replacement_fetcher.request_started.notified().await; @@ -1629,7 +1629,7 @@ async fn dropping_loader_cancels_in_flight_startup_and_refresh() { codex_home.path().to_path_buf(), CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - let (loader, _) = crate::bundle_loader::cloud_config_bundle_loader_for_service(service); + let (loader, _) = crate::bundle_loader::managed_config_bundle_loader_for_service(service); if starts_from_cache { assert_eq!(loader.get().await, Ok(Some(test_bundle()))); tokio::task::yield_now().await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index db35aaa6bb62..a6e9abe9bdfc 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -59,7 +59,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; @@ -408,7 +408,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result CloudConfigBundleLoader::default(), ) .await; - let gate_cloud_config = cloud_config_bundle_loader_for_storage( + let gate_cloud_config = chatgpt_config_bundle_loader_for_storage( embedded_network_policy .bind_bootstrap_auth(bootstrap_auth_config(&codex_home, &gate_bootstrap)?), /*enable_codex_api_key_env*/ false, @@ -508,7 +508,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result // API keys cannot fetch workspace-managed configuration. Preserve the // existing ChatGPT bootstrap identity even when model requests allow // CODEX_API_KEY. - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( + let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( bootstrap_auth_config, /*enable_codex_api_key_env*/ false, ) diff --git a/codex-rs/model-provider/src/lib.rs b/codex-rs/model-provider/src/lib.rs index 2fc5f1cf8d71..7aa8a0a4540b 100644 --- a/codex-rs/model-provider/src/lib.rs +++ b/codex-rs/model-provider/src/lib.rs @@ -42,6 +42,7 @@ pub use provider::ProviderAuthRecoveryMessages; pub use provider::ProviderUnauthorizedRecovery; pub use provider::SharedModelProvider; pub use provider::create_model_provider; +pub use provider::provider_uses_first_party_auth_path; #[cfg(test)] #[path = "workspace_routing_tests.rs"] diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 15aeffe06253..ce3cb216f113 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -333,7 +333,7 @@ pub type ModelProviderFuture<'a, T> = Pin + Send + 'a /// Shared runtime model provider handle. pub type SharedModelProvider = Arc; -fn provider_uses_first_party_auth_path(provider: &ModelProviderInfo) -> bool { +pub fn provider_uses_first_party_auth_path(provider: &ModelProviderInfo) -> bool { provider.requires_openai_auth && provider.env_key.is_none() && provider.experimental_bearer_token.is_none() diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c31c2d4da286..113117caf4fd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -47,7 +47,7 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; -use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; @@ -1098,7 +1098,7 @@ async fn cloud_config_bundle_for_app_server_target( codex_home: &Path, embedded_network_policy: &codex_app_server_client::EmbeddedNetworkPolicy, ) -> std::io::Result { - cloud_config_bundle_loader_for_storage( + chatgpt_config_bundle_loader_for_storage( embedded_network_policy .bind_bootstrap_auth(app_server_target.auth_config_for_cloud_loader( bootstrap_auth_config(codex_home, bootstrap_config)?, @@ -1415,7 +1415,7 @@ async fn run_ratatui_app( // bundle and rebuild config. This avoids missing newly available cloud-managed // policy due to login status detection edge cases. if show_login_screen && !uses_remote_workspace && !workload_identity_selected { - cloud_config_bundle = cloud_config_bundle_loader_for_storage( + cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( embedded_network_policy.bind_bootstrap_auth(initial_config.auth_config()), /*enable_codex_api_key_env*/ false, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2cdc98ca68e4..e83a61f266d6 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1126,7 +1126,7 @@ mod tests { use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_client::RemoteAppServerEndpoint; use codex_arg0::Arg0DispatchPaths; - use codex_cloud_config::cloud_config_bundle_loader_for_storage; + use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -1234,7 +1234,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: Default::default(), strict_config: false, - cloud_config_bundle: cloud_config_bundle_loader_for_storage( + cloud_config_bundle: chatgpt_config_bundle_loader_for_storage( auth_config.clone(), /*enable_codex_api_key_env*/ false, ) diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index d3fde7278f62..27081eb8b009 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -508,7 +508,7 @@ pub(super) async fn run_main_inner( cloud_config_bundle } else { startup_draft - .run_until(cloud_config_bundle_loader_for_storage( + .run_until(chatgpt_config_bundle_loader_for_storage( embedded_network_policy.bind_bootstrap_auth( app_server_target.auth_config_for_cloud_loader(config.auth_config()), ), diff --git a/codex-rs/windows-sandbox-service/src/machine_policy.rs b/codex-rs/windows-sandbox-service/src/machine_policy.rs index 157ac3897c00..971be1d3c60d 100644 --- a/codex-rs/windows-sandbox-service/src/machine_policy.rs +++ b/codex-rs/windows-sandbox-service/src/machine_policy.rs @@ -4,7 +4,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; -use codex_cloud_config::cloud_config_bundle_loader_for_storage_without_cache; +use codex_cloud_config::chatgpt_config_bundle_loader_for_storage_without_cache; use codex_config::ConfigLoadOptions; use codex_config::ConfigRequirementsToml; use codex_config::WindowsSandboxImplementationToml; @@ -75,7 +75,7 @@ pub(crate) fn validate_provisioning_request( .context("load bootstrap configuration")?; // Use the default cloud-policy endpoint unless managed requirements override it. bootstrap_config.config_toml.chatgpt_base_url = None; - let cloud_config_bundle = cloud_config_bundle_loader_for_storage_without_cache( + let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage_without_cache( bootstrap_auth_config(codex_home, &bootstrap_config) .context("resolve cloud configuration authentication")?, /*enable_codex_api_key_env*/ false, From 2d4c131524206afa898a53f96413d779718fd978 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 23 Oct 2026 19:20:21 +0000 Subject: [PATCH 007/287] Update prebuilt argument-comment-lint to `rust-v0.164.0` (#57170) ## What changed - Refresh the DotSlash artifact URLs, sizes, and digests for all four supported platforms. - Update the documented toolchain to `nightly-2026-08-20` and clarify how the bundled runner and repository wrapper normalize Dylint library filenames. GitOrigin-RevId: f78190dd5fdab81e5accd20e811af51524c0864a --- tools/argument-comment-lint/README.md | 12 +++---- .../argument-comment-lint | 32 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/tools/argument-comment-lint/README.md b/tools/argument-comment-lint/README.md index 0cf46543bc86..60d5145037cc 100644 --- a/tools/argument-comment-lint/README.md +++ b/tools/argument-comment-lint/README.md @@ -83,7 +83,7 @@ The package is not a full Rust toolchain. Running the prebuilt path still requires the pinned nightly toolchain to be installed via `rustup`: ```bash -rustup toolchain install nightly-2025-09-18 \ +rustup toolchain install nightly-2026-08-20 \ --component llvm-tools-preview \ --component rustc-dev \ --component rust-src @@ -105,7 +105,7 @@ argument-comment-lint/ argument-comment-lint cargo-dylint lib/ - libargument_comment_lint@nightly-2025-09-18-.dylib|so + libargument_comment_lint@-.dylib|so ``` On Windows the same layout is published as a `.zip`, with `.exe` and `.dll` @@ -114,13 +114,13 @@ filenames instead. DotSlash resolves the package entrypoint to `argument-comment-lint/bin/argument-comment-lint` (or `.exe` on Windows). That runner finds the sibling bundled `cargo-dylint` binary and the single packaged Dylint library under `lib/`, normalizes the -host-qualified nightly filename to the plain `nightly-2025-09-18` channel when +library filename to the nightly channel without the host triple when needed, and then invokes `cargo-dylint dylint --lib-path ` with the repo's default `DYLINT_RUSTFLAGS` and `CARGO_INCREMENTAL=0` settings. -The checked-in `run-prebuilt-linter.py` wrapper uses the fetched package -contents directly so the current checked-in alpha artifact works the same way. -It also makes sure the `rustup` shims stay ahead of any direct toolchain +The repository's `run-prebuilt-linter.py` wrapper invokes the bundled +`cargo-dylint` and normalizes the library filename itself. It makes sure +the `rustup` shims stay ahead of any direct toolchain `cargo` binary on `PATH`, and sets `RUSTUP_HOME` from `rustup show home` when the environment does not already provide it. That extra `RUSTUP_HOME` export is required for the current Windows Dylint driver path. diff --git a/tools/argument-comment-lint/argument-comment-lint b/tools/argument-comment-lint/argument-comment-lint index 602117e3ce2a..d74876027b1a 100755 --- a/tools/argument-comment-lint/argument-comment-lint +++ b/tools/argument-comment-lint/argument-comment-lint @@ -4,73 +4,73 @@ "name": "argument-comment-lint", "platforms": { "macos-aarch64": { - "size": 3402747, + "size": 3342901, "hash": "blake3", - "digest": "a11669d2f184a2c6f226cedce1bf10d1ec478d53413c42fe80d17dd873fdb2d7", + "digest": "eba9eb7196d34af64ef282f33f1588000857e9161a19ba9447038ef5e623bd99", "format": "tar.gz", "path": "argument-comment-lint/bin/argument-comment-lint", "providers": [ { - "url": "https://github.com/openai/codex/releases/download/rust-v0.117.0-alpha.2/argument-comment-lint-aarch64-apple-darwin.tar.gz" + "url": "https://github.com/openai/codex/releases/download/rust-v0.164.0/argument-comment-lint-aarch64-apple-darwin.tar.gz" }, { "type": "github-release", "repo": "https://github.com/openai/codex", - "tag": "rust-v0.117.0-alpha.2", + "tag": "rust-v0.164.0", "name": "argument-comment-lint-aarch64-apple-darwin.tar.gz" } ] }, "linux-x86_64": { - "size": 3869711, + "size": 3809665, "hash": "blake3", - "digest": "1015f4ba07d57edc5ec79c8f6709ddc1516f64c903e909820437a4b89d8d853a", + "digest": "f8c51999175df9bfd545b5454356c1bba62e0957d99ef06198a93283c01dc560", "format": "tar.gz", "path": "argument-comment-lint/bin/argument-comment-lint", "providers": [ { - "url": "https://github.com/openai/codex/releases/download/rust-v0.117.0-alpha.2/argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz" + "url": "https://github.com/openai/codex/releases/download/rust-v0.164.0/argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz" }, { "type": "github-release", "repo": "https://github.com/openai/codex", - "tag": "rust-v0.117.0-alpha.2", + "tag": "rust-v0.164.0", "name": "argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz" } ] }, "linux-aarch64": { - "size": 3759446, + "size": 3735760, "hash": "blake3", - "digest": "91f2a31e6390ca728ad09ae1aa6b6f379c67d996efcc22956001df89f068af5b", + "digest": "350baa8efbef8f743da3f74947e3598d0fe79666cefc4fcec25618e01c74e5cb", "format": "tar.gz", "path": "argument-comment-lint/bin/argument-comment-lint", "providers": [ { - "url": "https://github.com/openai/codex/releases/download/rust-v0.117.0-alpha.2/argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz" + "url": "https://github.com/openai/codex/releases/download/rust-v0.164.0/argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz" }, { "type": "github-release", "repo": "https://github.com/openai/codex", - "tag": "rust-v0.117.0-alpha.2", + "tag": "rust-v0.164.0", "name": "argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz" } ] }, "windows-x86_64": { - "size": 3244599, + "size": 3271160, "hash": "blake3", - "digest": "dc711c6d85b1cabbe52447dda3872deb20c2e64b155da8be0ecb207c7c391683", + "digest": "a2d1c8f71d9acf3885dd469876a249b769dd93e117c7a43192994e18d43e9027", "format": "zip", "path": "argument-comment-lint/bin/argument-comment-lint.exe", "providers": [ { - "url": "https://github.com/openai/codex/releases/download/rust-v0.117.0-alpha.2/argument-comment-lint-x86_64-pc-windows-msvc.zip" + "url": "https://github.com/openai/codex/releases/download/rust-v0.164.0/argument-comment-lint-x86_64-pc-windows-msvc.zip" }, { "type": "github-release", "repo": "https://github.com/openai/codex", - "tag": "rust-v0.117.0-alpha.2", + "tag": "rust-v0.164.0", "name": "argument-comment-lint-x86_64-pc-windows-msvc.zip" } ] From cf29971d93a6761906b9e8789f915902b5879e00 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Fri, 23 Oct 2026 19:29:42 +0000 Subject: [PATCH 008/287] Integrate runtime authentication into model requests (#57171) ## What changed - Add opt-in runtime authentication through `ModelClient::with_runtime_auth`, requiring the provider and runtime manager to share the selected OpenAI credentials. - Keep runtime identity, credentials, routing, and transport selection consistent for each request and its retries, signing a fresh assertion for each attempt. - Apply authentication asynchronously to WebSocket handshakes, replace stale authentication headers, and reconnect when the runtime changes. - Reject account, organization, or project selectors that conflict with the registered runtime, and preserve gateway OAuth composition. ## Testing Add coverage for dual-header and assertion-only authentication, HTTP retries, runtime replacement and account switches, stale WebSocket headers, selector mismatches, gateway OAuth failures, transport revocation, and Realtime compatibility. GitOrigin-RevId: 45b9efc2c80481406a0f6f6396a8cdd21d933c12 --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + .../src/transport/remote_control/auth.rs | 2 +- codex-rs/backend-client/src/client.rs | 4 +- .../backend-client/src/client/turn_usage.rs | 4 +- codex-rs/cloud-client/src/types.rs | 3 +- codex-rs/codex-api/src/auth.rs | 34 +- .../src/endpoint/responses_websocket.rs | 5 +- .../endpoint/responses_websocket/connector.rs | 5 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/client.rs | 219 ++++++-- .../core/src/client_runtime_auth_tests.rs | 209 ++++++++ codex-rs/core/src/client_tests.rs | 49 +- .../core/tests/suite/client_websockets.rs | 14 + .../suite/client_websockets_runtime_auth.rs | 468 ++++++++++++++++++ codex-rs/exec-server/src/remote.rs | 3 +- codex-rs/http-client/src/lib.rs | 4 + codex-rs/http-client/src/openai_headers.rs | 5 + codex-rs/login/src/auth/manager.rs | 3 +- .../model-provider-info/src/gateway_oauth.rs | 3 +- codex-rs/model-provider-info/src/lib.rs | 9 +- codex-rs/model-provider/src/auth.rs | 3 +- .../src/bearer_auth_provider.rs | 3 +- codex-rs/model-provider/src/combined_auth.rs | 13 + codex-rs/model-provider/src/provider.rs | 65 ++- codex-rs/runtime-agent-instance/src/auth.rs | 92 ++-- codex-rs/runtime-agent-instance/src/lib.rs | 1 - .../runtime-agent-instance/src/manager.rs | 11 + .../src/manager_tests.rs | 159 +++--- 29 files changed, 1189 insertions(+), 204 deletions(-) create mode 100644 codex-rs/core/src/client_runtime_auth_tests.rs create mode 100644 codex-rs/core/tests/suite/client_websockets_runtime_auth.rs create mode 100644 codex-rs/http-client/src/openai_headers.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 96316db08871..b9ab8a1d90e7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2989,6 +2989,7 @@ dependencies = [ "codex-rmcp-client", "codex-rollout", "codex-rollout-trace", + "codex-runtime-agent-instance", "codex-sandboxing", "codex-shell-command", "codex-skills", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d7bd76b11c5a..1602f7625ce7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -282,6 +282,7 @@ codex-response-debug-context = { path = "response-debug-context" } codex-rmcp-client = { path = "rmcp-client" } codex-rollout = { path = "rollout" } codex-rollout-trace = { path = "rollout-trace" } +codex-runtime-agent-instance = { path = "runtime-agent-instance" } codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/auth.rs b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs index e0e7f9e9dd58..a776638b6fa1 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/auth.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs @@ -61,7 +61,7 @@ impl RemoteControlAuth { } } -pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +pub(super) use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER as REMOTE_CONTROL_ACCOUNT_ID_HEADER; pub(super) struct RemoteControlConnectionAuth { pub(super) http_client_factory: codex_http_client::HttpClientFactory, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 86e94339090c..c20b2694e26d 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -10,6 +10,7 @@ use crate::types::TokenUsageProfile; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; @@ -299,10 +300,9 @@ impl Client { } self.auth_provider.add_auth_headers(&mut h)?; if let Some(acc) = &self.chatgpt_account_id - && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") && let Ok(hv) = HeaderValue::from_str(acc) { - h.insert(name, hv); + h.insert(CHATGPT_ACCOUNT_ID_HEADER, hv); } if self.chatgpt_account_is_fedramp && let Ok(name) = HeaderName::from_bytes(b"X-OpenAI-Fedramp") diff --git a/codex-rs/backend-client/src/client/turn_usage.rs b/codex-rs/backend-client/src/client/turn_usage.rs index fa8314e316ef..ecc8fadd23a9 100644 --- a/codex-rs/backend-client/src/client/turn_usage.rs +++ b/codex-rs/backend-client/src/client/turn_usage.rs @@ -1,5 +1,7 @@ use super::Client; use super::RequestError; +use codex_http_client::OPENAI_ORGANIZATION_HEADER; +use codex_http_client::OPENAI_PROJECT_HEADER; use http::HeaderMap; use http::Method; use http::header::CONTENT_TYPE; @@ -65,7 +67,7 @@ impl Client { let provider_scope_headers = provider_headers .iter() .filter(|(name, _)| { - ["openai-organization", "openai-project"] + [OPENAI_ORGANIZATION_HEADER, OPENAI_PROJECT_HEADER] .iter() .any(|allowed| name.as_str().eq_ignore_ascii_case(allowed)) }) diff --git a/codex-rs/cloud-client/src/types.rs b/codex-rs/cloud-client/src/types.rs index 666afc07def4..209113f57b8b 100644 --- a/codex-rs/cloud-client/src/types.rs +++ b/codex-rs/cloud-client/src/types.rs @@ -1,6 +1,7 @@ //! Caller-facing native gRPC types. Diagnostics never include credentials or server payloads. use bytes::Bytes; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use http::HeaderMap; use http::HeaderValue; use http::header::AUTHORIZATION; @@ -21,7 +22,7 @@ impl Credentials { for (name, value) in [ (AUTHORIZATION, format!("Bearer {bearer_token}")), ( - http::HeaderName::from_static("chatgpt-account-id"), + http::HeaderName::from_static(CHATGPT_ACCOUNT_ID_HEADER), account_id.to_owned(), ), ] { diff --git a/codex-rs/codex-api/src/auth.rs b/codex-rs/codex-api/src/auth.rs index 3ce6867eb284..6d1ab4f5131e 100644 --- a/codex-rs/codex-api/src/auth.rs +++ b/codex-rs/codex-api/src/auth.rs @@ -47,6 +47,19 @@ pub trait AuthProvider: Send + Sync { Ok(headers) } + /// Describes headers expected on a successful request without refreshing or registering auth. + /// Async providers should override this rather than resolving credentials for telemetry. + fn auth_header_telemetry(&self) -> Result { + let headers = self.to_auth_headers()?; + let name = headers + .contains_key(http::header::AUTHORIZATION) + .then_some("authorization"); + Ok(AuthHeaderTelemetry { + attached: name.is_some(), + name, + }) + } + /// Resolves auth headers for an outbound request. /// /// Unlike [`Self::to_auth_headers`], implementations may perform asynchronous work to refresh @@ -63,6 +76,15 @@ pub trait AuthProvider: Send + Sync { Box::pin(async { false }) } + /// Authenticates a header-only request, including removing credentials it supersedes. + /// WebSocket handshakes must use the returned map rather than overlaying resolved headers. + fn apply_auth_headers(&self, mut headers: HeaderMap) -> AuthHeadersFuture<'_> { + Box::pin(async move { + headers.extend(self.resolve_auth_headers().await?); + Ok(headers) + }) + } + /// Applies auth to a complete outbound request and returns the request to send. /// /// The input `request` is moved into this method. Implementations may mutate @@ -77,7 +99,7 @@ pub trait AuthProvider: Send + Sync { fn apply_auth(&self, request: Request) -> AuthProviderFuture<'_> { Box::pin(async move { let mut request = request; - request.headers.extend(self.resolve_auth_headers().await?); + request.headers = self.apply_auth_headers(request.headers).await?; Ok(request) }) } @@ -107,13 +129,5 @@ pub struct AuthHeaderTelemetry { } pub fn auth_header_telemetry(auth: &dyn AuthProvider) -> Result { - let mut headers = HeaderMap::new(); - auth.add_auth_headers(&mut headers)?; - let name = headers - .contains_key(http::header::AUTHORIZATION) - .then_some("authorization"); - Ok(AuthHeaderTelemetry { - attached: name.is_some(), - name, - }) + auth.auth_header_telemetry() } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index c59683d69dd8..12c157aa2ca6 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -464,9 +464,8 @@ impl ResponsesWebsocketClient { .websocket_url_for_path("/responses") .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; - let mut headers = - merge_request_headers(&self.provider.headers, extra_headers, default_headers); - self.auth.add_auth_headers(&mut headers)?; + let headers = merge_request_headers(&self.provider.headers, extra_headers, default_headers); + let headers = self.auth.apply_auth_headers(headers).await?; let connector = WebSocketConnector::new(http_client_factory) .map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))?; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket/connector.rs b/codex-rs/codex-api/src/endpoint/responses_websocket/connector.rs index 86e02c1847c4..802d613ade01 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket/connector.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket/connector.rs @@ -33,9 +33,8 @@ impl ResponsesWebsocketClient { .provider .websocket_url_for_path("/responses") .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; - let mut headers = - merge_request_headers(&self.provider.headers, extra_headers, default_headers); - self.auth.add_auth_headers(&mut headers)?; + let headers = merge_request_headers(&self.provider.headers, extra_headers, default_headers); + let headers = self.auth.apply_auth_headers(headers).await?; let (stream, _status, server_reasoning_included, server_model) = connect_websocket(ws_url, headers, connector, turn_state).await?; Ok(ResponsesWebsocketConnection::new( diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8fc0efd30d87..9bb640698ed5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -63,6 +63,7 @@ codex-response-debug-context = { workspace = true } codex-prompts = { workspace = true } codex-rollout = { workspace = true } codex-rollout-trace = { workspace = true } +codex-runtime-agent-instance = { workspace = true } codex-rmcp-client = { workspace = true } codex-sandboxing = { workspace = true } codex-skills = { workspace = true } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 99550cb20c42..a3a1429ee033 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -70,8 +70,12 @@ use codex_api::auth_header_telemetry; use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; +use codex_features::RuntimeAuthMode; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClientFactory; +use codex_http_client::OPENAI_ORGANIZATION_HEADER; +use codex_http_client::OPENAI_PROJECT_HEADER; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; @@ -84,6 +88,8 @@ use codex_otel::WEBSOCKET_CONTINUATION_COUNT_METRIC; use codex_otel::current_span_w3c_trace_context; use codex_protocol::ResponseItemId; use codex_protocol::auth::AuthMode; +use codex_runtime_agent_instance::ActorInfo; +use codex_runtime_agent_instance::RuntimeAuthManager; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -143,6 +149,7 @@ use codex_login::auth_env_telemetry::collect_auth_env_telemetry; use codex_model_provider::AgentIdentitySessionFallback; use codex_model_provider::ProviderAuthScope; use codex_model_provider::ProviderUnauthorizedRecovery; +use codex_model_provider::ResolvedProviderAuth; use codex_model_provider::ResponsesConnectionKey; use codex_model_provider::SharedModelProvider; use codex_model_provider::WorkspaceRoutingContext; @@ -238,9 +245,33 @@ struct CurrentClientSetup { auth_owner_generation: Option, auth_revision: Option, api_provider: ApiProvider, + http_client_factory: HttpClientFactory, redirect_policy: ClientRedirectPolicy, api_auth: SharedAuthProvider, agent_identity_telemetry: Option, + runtime: Option<(String, ActorInfo)>, +} + +impl CurrentClientSetup { + fn build_api_transport(&self, endpoint: &str) -> Result { + let api_provider = &self.api_provider; + let redirect_policy = if api_provider + .headers + .contains_key(codex_model_provider::ACCOUNT_ROUTING_HEADER) + { + ClientRedirectPolicy::Reject + } else { + self.redirect_policy + }; + let client = create_client_for_route( + &self.http_client_factory, + &api_provider.url_for_path(endpoint), + ClientRouteClass::Api, + redirect_policy, + ) + .map_err(std::io::Error::from)?; + Ok(ReqwestTransport::from_http_client(client)) + } } #[derive(Clone, Copy)] @@ -279,6 +310,7 @@ pub struct ModelClient { executed_tool_calls: Option, // Resolved once when the session is created, like other session feature flags. api_key_cyber_access_programs: cyber_access_program::ApiKeyCyberAccessPrograms, + runtime_auth: Option<(Arc, RuntimeAuthMode)>, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -332,6 +364,7 @@ struct WebsocketContinuation { struct WebsocketSession { connection: Option, responses_headers: ApiHeaderMap, + runtime_id: Option, /// Owner of the cached state, including before a connection is opened. auth_owner_generation: Option, connection_key: Option, @@ -610,6 +643,7 @@ impl ModelClient { codex_responses_headers: None, event_sender: None, http_client_factory, + runtime_auth: None, restored_history: false, request_contributors, executed_tool_calls: None, @@ -634,6 +668,31 @@ impl ModelClient { self } + /// Use the harness-owned runtime for this first-party provider. The owner must share this + /// client's credential manager. Provider-specific overrides need their own runtime owner. + pub fn with_runtime_auth( + mut self, + manager: Arc, + mode: RuntimeAuthMode, + ) -> Result { + let info = self.state.provider.info(); + if !info.requires_openai_auth + || info.env_key.is_some() + || info.experimental_bearer_token.is_some() + || info.auth.is_some() + || info.aws.is_some() + || !self + .auth_manager() + .is_some_and(|auth| manager.uses_credentials(&auth)) + { + return Err(CodexErr::UnsupportedOperation( + "runtime auth requires the model provider's selected OpenAI credential".to_string(), + )); + } + self.runtime_auth = Some((manager, mode)); + Ok(self) + } + pub(crate) fn with_session_context( mut self, prompt_cache_key: PromptCacheKey, @@ -766,8 +825,8 @@ impl ModelClient { ) -> Result { // Create the media call over HTTP first, then retain matching auth so realtime can attach // the server-side control WebSocket to the call id from that HTTP response. - let client_setup = self - .current_client_setup(ClientRouting::ConfiguredProvider) + let mut client_setup = self + .provider_client_setup(ClientRouting::ConfiguredProvider) .await?; if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); @@ -777,16 +836,15 @@ impl ModelClient { sideband_websocket_auth_headers(client_setup.api_auth.as_ref()) .map_err(|error| self.state.provider.map_api_error(error))?, ); - let api_provider = api_provider_override.unwrap_or(client_setup.api_provider); - let transport = self.build_api_transport( - &api_provider, - REALTIME_CALLS_ENDPOINT, - client_setup.redirect_policy, - )?; - let response = ApiRealtimeCallClient::new(transport, api_provider, client_setup.api_auth) - .create_with_session_and_headers(sdp, session_config, extra_headers) - .await - .map_err(|error| self.state.provider.map_api_error(error))?; + if let Some(api_provider) = api_provider_override { + client_setup.api_provider = api_provider; + } + let transport = client_setup.build_api_transport(REALTIME_CALLS_ENDPOINT)?; + let response = + ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth) + .create_with_session_and_headers(sdp, session_config, extra_headers) + .await + .map_err(|error| self.state.provider.map_api_error(error))?; Ok(RealtimeWebrtcCallStart { sdp: response.sdp, call_id: response.call_id, @@ -799,7 +857,7 @@ impl ModelClient { mut extra_headers: ApiHeaderMap, ) -> Result { let client_setup = self - .current_client_setup(ClientRouting::ConfiguredProvider) + .provider_client_setup(ClientRouting::ConfiguredProvider) .await?; if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); @@ -831,11 +889,7 @@ impl ModelClient { let client_setup = self .current_client_setup(ClientRouting::ConfiguredProvider) .await?; - let transport = self.build_api_transport( - &client_setup.api_provider, - MEMORIES_SUMMARIZE_ENDPOINT, - client_setup.redirect_policy, - )?; + let transport = client_setup.build_api_transport(MEMORIES_SUMMARIZE_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -1159,6 +1213,62 @@ impl ModelClient { /// This centralizes setup used by both prewarm and normal request paths so they stay in /// lockstep when auth/provider resolution changes. async fn current_client_setup(&self, routing: ClientRouting) -> Result { + if let Some((manager, mode)) = &self.runtime_auth { + let selected = manager.auth().await.map_err(|err| { + self.state + .provider + .map_api_error(codex_api::AuthError::from(err).into()) + })?; + let auth = selected.auth().clone(); + let auth_revision = Some(selected.change_state().generation); + let auth_owner_generation = Some(selected.change_state().owner_generation); + let api_provider = self.state.provider.api_provider_for_auth(Some(&auth))?; + let (api_provider, redirect_policy) = match routing { + ClientRouting::Workspace => { + let resolved = self + .state + .provider + .route_responses_api_provider( + api_provider, + Some(&auth), + &self.state.workspace_routing, + ) + .await?; + (resolved.provider, resolved.redirect_policy) + } + ClientRouting::ConfiguredProvider => (api_provider, ClientRedirectPolicy::Default), + }; + let runtime = selected.runtime(); + let actor = runtime.actor().info().clone(); + validate_runtime_selectors(&api_provider.headers, &actor)?; + let runtime = Some((runtime.id().to_string(), actor)); + let http_client_factory = selected.http_client_factory().clone(); + let resolved_auth = self + .state + .provider + .compose_auth(ResolvedProviderAuth { + auth: selected.into_auth_provider(*mode), + agent_identity_telemetry: None, + }) + .await?; + tracing::debug!(runtime_auth_mode = ?mode, "selected runtime authentication"); + return Ok(CurrentClientSetup { + auth: Some(auth), + auth_owner_generation, + auth_revision, + api_provider, + http_client_factory, + redirect_policy, + api_auth: resolved_auth.auth, + agent_identity_telemetry: None, + runtime, + }); + } + self.provider_client_setup(routing).await + } + + // Realtime keeps provider auth until its runtime integration lands later in the stack. + async fn provider_client_setup(&self, routing: ClientRouting) -> Result { // Capture before resolving credentials so an account switch during setup cannot label // an old connection with the new owner's revision. let auth_owner_generation = self.auth_owner_generation(); @@ -1215,9 +1325,11 @@ impl ModelClient { auth_owner_generation, auth_revision: revision, api_provider, + http_client_factory: self.http_client_factory.clone(), redirect_policy, api_auth: resolved_auth.auth, agent_identity_telemetry: resolved_auth.agent_identity_telemetry, + runtime: None, }); } } @@ -1296,30 +1408,6 @@ impl ModelClient { HeaderValue::from_str(&routing_hint).ok() } - fn build_api_transport( - &self, - api_provider: &ApiProvider, - endpoint: &str, - redirect_policy: ClientRedirectPolicy, - ) -> Result { - let redirect_policy = if api_provider - .headers - .contains_key(codex_model_provider::ACCOUNT_ROUTING_HEADER) - { - ClientRedirectPolicy::Reject - } else { - redirect_policy - }; - let client = create_client_for_route( - &self.http_client_factory, - &api_provider.url_for_path(endpoint), - ClientRouteClass::Api, - redirect_policy, - ) - .map_err(std::io::Error::from)?; - Ok(ReqwestTransport::from_http_client(client)) - } - pub(crate) async fn prewarm_auth(&self) -> Result<()> { self.current_client_setup(ClientRouting::Workspace) .await @@ -1336,6 +1424,7 @@ impl ModelClient { session_telemetry: &SessionTelemetry, api_provider: codex_api::Provider, api_auth: SharedAuthProvider, + http_client_factory: &HttpClientFactory, responses_metadata: &CodexResponsesMetadata, auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, @@ -1354,7 +1443,7 @@ impl ModelClient { let result = match tokio::time::timeout( websocket_connect_timeout, ApiWebSocketResponsesClient::new(api_provider, api_auth).connect( - &self.http_client_factory, + http_client_factory, headers, codex_login::default_client::default_headers(), /*turn_state*/ None, @@ -1645,11 +1734,13 @@ impl ModelClientSession { api_provider: client_setup.api_provider, auth_revision: client_setup.auth_revision, api_auth: client_setup.api_auth, + http_client_factory: client_setup.http_client_factory, auth_owner_generation: client_setup.auth_owner_generation, responses_metadata: &websocket_metadata, auth_context, request_route_telemetry: RequestRouteTelemetry::for_endpoint("/responses"), responses_headers: &responses_headers, + runtime_id: client_setup.runtime.map(|(id, _)| id), }) .await { @@ -1703,17 +1794,20 @@ impl ModelClientSession { api_provider, auth_revision, api_auth, + http_client_factory, auth_owner_generation, responses_metadata, auth_context, request_route_telemetry, responses_headers, + runtime_id, } = params; let connection_key = ResponsesConnectionKey::new(&api_provider, auth_revision); let reset_reason = match self.websocket_session.connection.as_ref() { Some(_) if self.websocket_session.responses_headers != *responses_headers - || self.websocket_session.connection_key.as_ref() != Some(&connection_key) => + || self.websocket_session.connection_key.as_ref() != Some(&connection_key) + || self.websocket_session.runtime_id != runtime_id => { Some("other") } @@ -1740,6 +1834,7 @@ impl ModelClientSession { session_telemetry, api_provider, api_auth, + &http_client_factory, responses_metadata, auth_context, request_route_telemetry, @@ -1748,6 +1843,7 @@ impl ModelClientSession { .await?; self.websocket_session.connection = Some(new_conn); self.websocket_session.responses_headers = responses_headers.clone(); + self.websocket_session.runtime_id = runtime_id; self.websocket_session.auth_owner_generation = auth_owner_generation; self.websocket_session.connection_key = Some(connection_key); self.websocket_session @@ -1835,11 +1931,7 @@ impl ResponsesHttpExecutor<'_> { .client .responses_headers(client_setup.auth.as_ref(), &model_info.slug); tracing::Span::current().record("api.path", "/responses"); - let transport = self.client.build_api_transport( - &client_setup.api_provider, - "/responses", - client_setup.redirect_policy, - )?; + let transport = client_setup.build_api_transport("/responses")?; let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), client_setup.api_auth.as_ref(), @@ -2075,7 +2167,9 @@ impl ModelClientSession { api_provider: client_setup.api_provider, auth_revision: client_setup.auth_revision, api_auth: client_setup.api_auth, + http_client_factory: client_setup.http_client_factory, auth_owner_generation: client_setup.auth_owner_generation, + runtime_id: client_setup.runtime.map(|(id, _)| id), responses_metadata: &websocket_metadata, auth_context: request_auth_context, request_route_telemetry: RequestRouteTelemetry::for_endpoint("/responses"), @@ -2783,11 +2877,38 @@ impl AuthRequestTelemetryContext { } } +fn validate_runtime_selectors(headers: &ApiHeaderMap, actor: &ActorInfo) -> Result<()> { + let (account, organization, project) = match actor { + ActorInfo::Chatgpt { account_id, .. } => (Some(account_id.as_str()), None, None), + ActorInfo::Platform { + organization_id, + project_id, + .. + } => (None, Some(organization_id.as_str()), project_id.as_deref()), + }; + for (name, expected) in [ + (CHATGPT_ACCOUNT_ID_HEADER, account), + (OPENAI_ORGANIZATION_HEADER, organization), + (OPENAI_PROJECT_HEADER, project), + ] { + if let Some(value) = headers.get(name) + && value.to_str().ok() != expected + { + return Err(CodexErr::UnsupportedOperation(format!( + "configured {name} does not match the registered runtime" + ))); + } + } + Ok(()) +} + struct WebsocketConnectParams<'a> { + runtime_id: Option, session_telemetry: &'a SessionTelemetry, api_provider: codex_api::Provider, auth_revision: Option, api_auth: SharedAuthProvider, + http_client_factory: HttpClientFactory, auth_owner_generation: Option, responses_metadata: &'a CodexResponsesMetadata, auth_context: AuthRequestTelemetryContext, diff --git a/codex-rs/core/src/client_runtime_auth_tests.rs b/codex-rs/core/src/client_runtime_auth_tests.rs new file mode 100644 index 000000000000..2255a35b04c7 --- /dev/null +++ b/codex-rs/core/src/client_runtime_auth_tests.rs @@ -0,0 +1,209 @@ +//! Exercise selected transport revocation and Realtime compatibility with runtime auth enabled. +#![cfg(test)] + +use super::*; +use crate::client::ClientRouting; +use crate::client::RequestRouteTelemetry; +use codex_client::HttpTransport; +use codex_http_client::DestinationPolicy; +use codex_http_client::NetworkPolicyController; +use codex_http_client::NetworkPolicyDenied; +use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; +use codex_login::AuthRouteConfig; +use pretty_assertions::assert_eq; + +#[test_case::test_case(RuntimeAuthMode::DualHeader; "dual_header")] +#[test_case::test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")] +#[tokio::test] +async fn selected_runtime_transport_stays_revoked(mode: RuntimeAuthMode) { + let server = MockServer::start().await; + Mock::given(path("/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "runtime_agent_instance_id": "runtime-a", "agent_host_id": "host-a", + "actor": {"kind": "platform", "user_id": "user-a", "organization_id": "org-a", "project_id": null}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/v1/responses")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let controller = NetworkPolicyController::default(); + let policy = controller.policy(); + let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault) + .with_network_policy(policy.clone()); + let home = TempDir::new().unwrap(); + codex_login::login_with_api_key( + home.path(), + "key-a", + AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::Direct, + ) + .unwrap(); + let manager = Arc::new( + AuthManager::new( + home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + AuthRouteConfig::from_http_client_factory(factory.clone()), + ) + .await, + ); + assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); + let runtime = Arc::new(RuntimeAuthManager::new( + manager.clone(), + AgentHostAuthorization::InstallationId { + installation_id: uuid::Uuid::nil(), + }, + RuntimeRegistrationMetadata { + harness: None, + harness_version: None, + role: RuntimeRole::OrchestratorAndExecutor, + }, + format!("{}/register", server.uri()).parse().unwrap(), + )); + let mut client = test_model_client(SessionSource::Exec); + client.http_client_factory = factory; + Arc::get_mut(&mut client.state).unwrap().provider = create_model_provider( + ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri()))), + Some(manager), + ); + let client = client.with_runtime_auth(runtime, mode).unwrap(); + let setup = client + .current_client_setup(ClientRouting::Workspace) + .await + .unwrap(); + let transport = setup.build_api_transport("/responses").unwrap(); + let request = setup + .api_provider + .build_request(http::Method::POST, "/responses"); + let request = setup.api_auth.apply_auth(request).await.unwrap(); + transport.execute(request.clone()).await.unwrap(); + + // A new policy may allow the destination, but cannot authorize the old account's request. + policy.invalidate(); + assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); + assert!(matches!( + transport.execute(request).await, + Err(TransportError::Policy(NetworkPolicyDenied::Revoked)) + )); + let auth_context = AuthRequestTelemetryContext::new( + setup.auth.as_ref().map(CodexAuth::auth_mode), + setup.api_auth.as_ref(), + /*agent_identity_telemetry*/ None, + PendingUnauthorizedRetry::default(), + ) + .unwrap(); + let metadata = test_responses_metadata_for_client( + &client, + /*turn_id*/ None, + "test-session".into(), + /*parent_thread_id*/ None, + TestCodexResponsesRequestKind::WebsocketConnection, + ); + let result = client + .connect_websocket( + &test_session_telemetry(), + setup.api_provider, + setup.api_auth, + &setup.http_client_factory, + &metadata, + auth_context, + RequestRouteTelemetry::for_endpoint("/responses"), + &http::HeaderMap::new(), + ) + .await; + assert!(matches!( + result, + Err(ApiError::Transport(TransportError::Policy( + NetworkPolicyDenied::Revoked + ))) + )); + server.verify().await; +} + +#[test_case::test_case(RuntimeAuthMode::DualHeader; "dual_header")] +#[test_case::test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")] +#[tokio::test] +async fn realtime_keeps_provider_auth_until_runtime_integration(mode: RuntimeAuthMode) { + let (client, _) = model_client_with_counting_attestation(/*include_attestation*/ true); + let registration = MockServer::start().await; + let manager = client.auth_manager().unwrap(); + let expected_authorization = format!( + "Bearer {}", + manager.auth().await.unwrap().get_token().unwrap() + ); + let runtime = Arc::new(RuntimeAuthManager::new( + manager, + AgentHostAuthorization::InstallationId { + installation_id: uuid::Uuid::nil(), + }, + RuntimeRegistrationMetadata { + harness: None, + harness_version: None, + role: RuntimeRole::OrchestratorAndExecutor, + }, + format!("{}/register", registration.uri()).parse().unwrap(), + )); + let client = client.with_runtime_auth(runtime, mode).unwrap(); + let sideband = client + .realtime_sideband_headers(http::HeaderMap::new()) + .await + .unwrap(); + assert_eq!( + sideband[http::header::AUTHORIZATION], + expected_authorization + ); + assert!(!sideband.contains_key("x-openai-agent-assertion")); + + let server = MockServer::start().await; + Mock::given(path("/realtime/calls")) + .respond_with( + ResponseTemplate::new(201) + .insert_header("location", "/realtime/calls/rtc_test") + .set_body_string("answer"), + ) + .expect(1) + .mount(&server) + .await; + let mut provider = client.state.provider.api_provider().await.unwrap(); + provider.base_url = server.uri(); + let call = client + .create_realtime_call_with_headers( + "offer".into(), + codex_api::RealtimeSessionConfig { + instructions: "hi".into(), + initial_items: Vec::new(), + delegation_ack_filler: None, + model: Some("gpt-realtime".into()), + session_id: None, + event_parser: codex_api::RealtimeEventParser::V1, + session_mode: codex_api::RealtimeSessionMode::Conversational, + output_modality: codex_protocol::protocol::RealtimeOutputModality::Audio, + voice: codex_protocol::protocol::RealtimeVoice::Cove, + }, + http::HeaderMap::new(), + Some(provider), + ) + .await + .unwrap(); + assert_eq!( + (call.call_id.as_str(), call.sdp.as_str()), + ("rtc_test", "answer") + ); + assert_eq!( + call.sideband_headers[http::header::AUTHORIZATION], + expected_authorization + ); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].headers["authorization"], expected_authorization); + assert!(!requests[0].headers.contains_key("x-openai-agent-assertion")); + assert!(registration.received_requests().await.unwrap().is_empty()); +} diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 3e83bceaddc1..1e249231be04 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -1,3 +1,6 @@ +#[path = "client_runtime_auth_tests.rs"] +mod runtime_auth_tests; + use super::AuthRequestTelemetryContext; use super::ModelClient; use super::NonIncrementalReason; @@ -22,6 +25,7 @@ use codex_api::AgentIdentityTelemetry; use codex_api::ApiError; use codex_api::ResponseEvent; use codex_api::TransportError; +use codex_features::RuntimeAuthMode; use codex_history::HistoryInitialization; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; @@ -72,6 +76,10 @@ use codex_rollout_trace::RawTraceEventPayload; use codex_rollout_trace::RolloutTrace; use codex_rollout_trace::TraceWriter; use codex_rollout_trace::replay_bundle; +use codex_runtime_agent_instance::AgentHostAuthorization; +use codex_runtime_agent_instance::RuntimeAuthManager; +use codex_runtime_agent_instance::RuntimeRegistrationMetadata; +use codex_runtime_agent_instance::RuntimeRole; use codex_tools::JsonSchema; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; @@ -144,8 +152,13 @@ fn test_model_provider() -> SharedModelProvider { test_model_client(SessionSource::Cli).state.provider.clone() } +#[test_case::test_case(None; "credential_auth")] +#[test_case::test_case(Some(RuntimeAuthMode::DualHeader); "dual_header")] +#[test_case::test_case(Some(RuntimeAuthMode::AssertionOnly); "assertion_only")] #[tokio::test] -async fn workspace_routed_http_rejects_redirects_without_a_routing_header() { +async fn workspace_routed_http_rejects_redirects_without_a_routing_header( + runtime_mode: Option, +) { use codex_client::HttpTransport; use codex_login::WorkspaceRouting; use codex_login::WorkspaceRoutingRequest; @@ -191,11 +204,36 @@ async fn workspace_routed_http_rejects_redirects_without_a_routing_header() { AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let resolver: Arc = Arc::new(Routing(routing_override)); manager.set_workspace_routing_resolver(Arc::downgrade(&resolver)); + let registration = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "runtime_agent_instance_id": "runtime-a", + "agent_host_id": "host-a", + "actor": {"kind": "chatgpt", "user_id": "user-a", "account_id": "account_id"}, + }))) + .mount(®istration) + .await; + let runtime = Arc::new(RuntimeAuthManager::new( + manager.clone(), + AgentHostAuthorization::InstallationId { + installation_id: uuid::Uuid::nil(), + }, + RuntimeRegistrationMetadata { + harness: None, + harness_version: None, + role: RuntimeRole::OrchestratorAndExecutor, + }, + format!("{}/register", registration.uri()).parse().unwrap(), + )); let mut client = test_model_client(SessionSource::Exec); Arc::get_mut(&mut client.state).unwrap().provider = create_model_provider( ModelProviderInfo::create_openai_provider(/*base_url*/ None), Some(manager), ); + if let Some(mode) = runtime_mode { + client = client.with_runtime_auth(runtime, mode).unwrap(); + } let mut setup = client .current_client_setup(super::ClientRouting::Workspace) .await @@ -208,15 +246,14 @@ async fn workspace_routed_http_rejects_redirects_without_a_routing_header() { } // Exercise the resolved route's redirect policy against loopback HTTP servers. setup.api_provider.base_url = origin.uri(); - let transport = client - .build_api_transport(&setup.api_provider, "/responses", setup.redirect_policy) - .unwrap(); + let transport = setup.build_api_transport("/responses").unwrap(); let request = setup .api_provider .build_request(http::Method::POST, "/responses") .with_json(&json!({"input": "workspace content"})); let result = transport.execute(request).await; - if routing_override.is_some() { + let reject_redirects = routing_override.is_some(); + if reject_redirects { assert!( matches!( result, @@ -233,7 +270,7 @@ async fn workspace_routed_http_rejects_redirects_without_a_routing_header() { } assert_eq!( destination.received_requests().await.unwrap().len(), - usize::from(routing_override.is_none()) + usize::from(!reject_redirects) ); } } diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 5f8c3bf7175a..1486db4c2512 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -77,6 +77,9 @@ use tempfile::TempDir; use tracing::Instrument; use tracing_test::traced_test; +#[path = "client_websockets_runtime_auth.rs"] +mod runtime_auth_tests; + const MODEL: &str = "gpt-5.4"; const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; const USER_AGENT_HEADER: &str = "user-agent"; @@ -2863,6 +2866,17 @@ async fn websocket_harness_with_provider_options_and_auth( .with_metrics(metrics); let effort = None; let summary = ReasoningSummary::Auto; + let _gateway = provider.gateway_oauth.as_ref().map(|_| { + codex_model_provider::test_support::seed_gateway_auth( + &provider, + &auth_manager, + serde_json::json!({ + "access_token": "gateway-token", + "refresh_token": "refresh-token", + "expires_at": i64::MAX, + }), + ) + }); let client = ModelClient::new( client_auth_manager, AgentIdentityAuthPolicy::JwtOnly, diff --git a/codex-rs/core/tests/suite/client_websockets_runtime_auth.rs b/codex-rs/core/tests/suite/client_websockets_runtime_auth.rs new file mode 100644 index 000000000000..b9014324f022 --- /dev/null +++ b/codex-rs/core/tests/suite/client_websockets_runtime_auth.rs @@ -0,0 +1,468 @@ +//! Exercise runtime assertions and turn/socket lifetimes through real model transports. + +use super::*; +use base64::Engine; +use codex_features::RuntimeAuthMode; +use codex_login::ExternalAuth; +use codex_login::ExternalAuthFuture; +use codex_login::ExternalAuthRefreshContext; +use codex_model_provider_info::GatewayOAuthConfig; +use codex_model_provider_info::GatewayOAuthDelivery; +use codex_runtime_agent_instance::AgentHostAuthorization; +use codex_runtime_agent_instance::RuntimeAuthManager; +use codex_runtime_agent_instance::RuntimeRegistrationMetadata; +use codex_runtime_agent_instance::RuntimeRole; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::sync::RwLock; +use test_case::test_case; +use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +struct Credentials(RwLock); + +impl ExternalAuth for Credentials { + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(self.0.read().unwrap().clone()) }) + } + + fn refresh(&self, _: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { + self.resolve() + } +} + +fn registered(request: &Request) -> ResponseTemplate { + let body: Value = request.body_json().unwrap(); + let user = if body["actor_auth"]["credential"] == "key-other-user" { + "other-user" + } else { + "user-a" + }; + ResponseTemplate::new(200).set_body_json(json!({ + "runtime_agent_instance_id": request.headers["idempotency-key"].to_str().unwrap(), + "agent_host_id": "host-a", + "actor": {"kind": "platform", "user_id": user, "organization_id": "org-a", "project_id": "project-a"}, + })) +} + +async fn runtime_harness( + registration: &MockServer, + mut provider: ModelProviderInfo, + mode: RuntimeAuthMode, +) -> (WebsocketTestHarness, Arc) { + provider.requires_openai_auth = true; + let initial = CodexAuth::from_api_key("key-a"); + let mut harness = websocket_harness_with_provider_options_and_auth( + provider, + /*runtime_metrics_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, + &[], + Some(initial.clone()), + ) + .await; + let credentials = Arc::new(Credentials(RwLock::new(initial))); + harness + .auth_manager + .set_external_auth(credentials.clone()) + .await + .unwrap(); + let runtime = Arc::new(RuntimeAuthManager::new( + harness.auth_manager.clone(), + AgentHostAuthorization::InstallationId { + installation_id: Uuid::nil(), + }, + RuntimeRegistrationMetadata { + harness: Some("codex".into()), + harness_version: None, + role: RuntimeRole::OrchestratorAndExecutor, + }, + format!("{}/register", registration.uri()).parse().unwrap(), + )); + harness.client = harness.client.with_runtime_auth(runtime, mode).unwrap(); + (harness, credentials) +} + +fn gateway_config(server: &MockServer, delivery: GatewayOAuthDelivery) -> GatewayOAuthConfig { + GatewayOAuthConfig { + authorization_url: format!("{}/authorize", server.uri()), + token_url: format!("{}/token", server.uri()), + client_id: "client".into(), + resource: None, + scopes: vec![], + redirect_port: None, + delivery, + } +} + +fn assertion_payload( + authorization: &str, + assertion: Option, + mode: RuntimeAuthMode, + credential: &str, +) -> Value { + let assertion = match mode { + RuntimeAuthMode::DualHeader => { + assert_eq!(authorization, format!("Bearer {credential}")); + assertion.expect("dual-header assertion") + } + RuntimeAuthMode::AssertionOnly => { + assert!(assertion.is_none()); + assert!(!authorization.contains(credential)); + authorization + .strip_prefix("AgentAssertion ") + .unwrap() + .to_string() + } + }; + let parts: Vec<_> = assertion.split('.').collect(); + assert_eq!(parts.len(), 4); + assert_eq!(parts[0], "1"); + serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[2]) + .unwrap(), + ) + .unwrap() +} + +async fn complete( + session: &mut ModelClientSession, + harness: &WebsocketTestHarness, +) -> codex_protocol::error::Result<()> { + let prompt = prompt_with_input(vec![message_item("hello")]); + let mut stream = session + .stream( + &prompt, + &harness.model_info, + &harness.session_telemetry, + harness.effort.clone(), + harness.summary, + /*service_tier*/ None, + &turn_metadata(harness, /*turn_id*/ None), + &InferenceTraceContext::disabled(), + ) + .await?; + while let Some(event) = stream.next().await { + if matches!(event?, ResponseEvent::Completed { .. }) { + return Ok(()); + } + } + panic!("model stream ended before completion"); +} + +#[test_case(RuntimeAuthMode::DualHeader, false; "dual_header")] +#[test_case(RuntimeAuthMode::AssertionOnly, false; "assertion_only")] +#[test_case(RuntimeAuthMode::DualHeader, true; "dual_header_with_gateway")] +#[test_case(RuntimeAuthMode::AssertionOnly, true; "assertion_only_with_gateway")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn http_requests_keep_matching_runtime_and_credentials_across_retries_and_account_changes( + mode: RuntimeAuthMode, + gateway: bool, +) { + skip_if_no_network!(); + let server = MockServer::start().await; + Mock::given(path("/register")) + .respond_with(registered) + .expect(2) + .mount(&server) + .await; + Mock::given(path("/v1/responses")) + .respond_with(ResponseTemplate::new(503)) + .up_to_n_times(1) + .with_priority(1) + .mount(&server) + .await; + Mock::given(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(responses::sse(vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ])), + ) + .mount(&server) + .await; + let mut provider = + ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri()))); + provider.supports_websockets = false; + provider.request_max_retries = Some(1); + if gateway { + provider.gateway_oauth = Some(gateway_config( + &server, + GatewayOAuthDelivery::Header { + name: "x-gateway-auth".into(), + scheme: "Bearer".into(), + }, + )); + } + let (harness, credentials) = runtime_harness(&server, provider, mode).await; + let mut session = harness.client.new_session(); + complete(&mut session, &harness).await.unwrap(); + *credentials.0.write().unwrap() = CodexAuth::from_api_key("key-other-user"); + complete(&mut session, &harness).await.unwrap(); + let requests = server.received_requests().await.unwrap(); + let bootstraps: Vec<_> = requests + .iter() + .filter(|r| r.url.path() == "/register") + .collect(); + assert_eq!(bootstraps.len(), 2); + assert!( + bootstraps + .iter() + .all(|r| !r.headers.contains_key("authorization")) + ); + let attempts: Vec<_> = requests + .iter() + .filter(|r| r.url.path() == "/v1/responses") + .collect(); + assert_eq!(attempts.len(), 3); + let proofs: Vec<_> = attempts + .iter() + .zip(["key-a", "key-a", "key-other-user"]) + .map(|(r, credential)| { + assert_eq!( + r.headers + .get("x-gateway-auth") + .map(|value| value.to_str().unwrap()), + gateway.then_some("Bearer gateway-token"), + ); + assertion_payload( + r.headers["authorization"].to_str().unwrap(), + r.headers + .get("x-openai-agent-assertion") + .map(|v| v.to_str().unwrap().to_string()), + mode, + credential, + ) + }) + .collect(); + assert_eq!( + proofs[0]["runtime_agent_instance"], + bootstraps[0].headers["idempotency-key"].to_str().unwrap() + ); + assert_eq!( + proofs[0]["runtime_agent_instance"], + proofs[1]["runtime_agent_instance"] + ); + assert_ne!(proofs[0]["id"], proofs[1]["id"]); + assert_eq!( + proofs[2]["runtime_agent_instance"], + bootstraps[1].headers["idempotency-key"].to_str().unwrap() + ); + assert_ne!( + proofs[0]["runtime_agent_instance"], + proofs[2]["runtime_agent_instance"] + ); +} + +#[test_case(RuntimeAuthMode::DualHeader, false; "dual_header")] +#[test_case(RuntimeAuthMode::AssertionOnly, false; "assertion_only")] +#[test_case(RuntimeAuthMode::DualHeader, true; "dual_header_with_gateway")] +#[test_case(RuntimeAuthMode::AssertionOnly, true; "assertion_only_with_gateway")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_reconnects_for_replacement_and_account_switch_within_a_turn( + mode: RuntimeAuthMode, + gateway: bool, +) { + skip_if_no_network!(); + let registration = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(registered) + .expect(3) + .mount(®istration) + .await; + let response = vec![ + ev_response_created("resp-1"), + json!({"type": "response.metadata", "headers": {"x-codex-turn-state": "runtime-state"}}), + ev_completed("resp-1"), + ]; + let server = start_websocket_server(vec![ + // Leave one unread response on each old connection so the server does not close it. + // Reconnection must follow runtime replacement, not a peer-initiated close. + vec![response.clone(), response.clone(), response.clone()], + vec![response.clone(), response.clone()], + vec![response], + ]) + .await; + let mut provider = websocket_provider(&server); + if gateway { + provider.base_url = Some(format!( + "{}/v1", + server.uri().replacen("ws://", "http://", 1) + )); + provider.gateway_oauth = Some(gateway_config( + ®istration, + GatewayOAuthDelivery::Cookie { + name: "gateway-session".into(), + }, + )); + } + provider.http_headers = Some(std::collections::HashMap::from([ + ("authorization".into(), "Bearer stale-credential".into()), + ("x-openai-agent-assertion".into(), "stale-assertion".into()), + ("x-openai-fedramp".into(), "true".into()), + ("x-test-provider-header".into(), "preserved".into()), + ])); + let (harness, credentials) = runtime_harness(®istration, provider, mode).await; + let mut session = harness.client.new_session(); + let mut input = vec![message_item("first")]; + stream_until_complete(&mut session, &harness, &prompt_with_input(input.clone())).await; + input.push(message_item("second")); + stream_until_complete(&mut session, &harness, &prompt_with_input(input.clone())).await; + assert_eq!(server.handshakes().len(), 1); + + *credentials.0.write().unwrap() = CodexAuth::from_api_key("key-b"); + session + .preconnect_websocket( + &harness.model_info, + /*service_tier*/ None, + &harness.session_telemetry, + &websocket_connection_metadata(&harness), + ) + .await + .unwrap(); + input.push(message_item("third")); + stream_until_complete(&mut session, &harness, &prompt_with_input(input.clone())).await; + assert_eq!(server.handshakes().len(), 2); + + *credentials.0.write().unwrap() = CodexAuth::from_api_key("key-other-user"); + input.push(message_item("fourth")); + stream_until_complete(&mut session, &harness, &prompt_with_input(input)).await; + let handshakes = server.handshakes(); + assert_eq!(handshakes.len(), 3); + let runtime_ids: Vec<_> = handshakes + .iter() + .zip(["key-a", "key-b", "key-other-user"]) + .map(|(h, credential)| { + assert_eq!( + h.header("cookie").as_deref(), + gateway.then_some("gateway-session=gateway-token"), + ); + assert!(h.header("x-openai-fedramp").is_none()); + assert_eq!( + h.header("x-test-provider-header").as_deref(), + Some("preserved") + ); + assertion_payload( + &h.header("authorization").unwrap(), + h.header("x-openai-agent-assertion"), + mode, + credential, + )["runtime_agent_instance"] + .clone() + }) + .collect(); + assert_ne!(runtime_ids[0], runtime_ids[1]); + assert_ne!(runtime_ids[1], runtime_ids[2]); + assert_eq!( + server + .connections() + .iter() + .map(Vec::len) + .collect::>(), + vec![2, 1, 1] + ); + let connections = server.connections(); + assert_eq!( + connections[0][1].body_json()["previous_response_id"], + "resp-1" + ); + assert_eq!( + connections[0][1].body_json()["client_metadata"]["x-codex-turn-state"], + "runtime-state" + ); + for (index, requests) in connections.iter().enumerate().skip(1) { + let body = requests[0].body_json(); + assert_eq!(body["previous_response_id"], Value::Null); + assert_eq!(body["client_metadata"]["x-codex-turn-state"], Value::Null); + assert_eq!(body["input"].as_array().unwrap().len(), index + 2); + } + server.shutdown().await; +} + +#[tokio::test] +async fn mismatched_resource_selector_never_sends_model_request() { + skip_if_no_network!(); + let server = MockServer::start().await; + Mock::given(path("/register")) + .respond_with(registered) + .mount(&server) + .await; + let mut provider = + ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri()))); + provider.supports_websockets = false; + provider.http_headers = + Some([(String::from("OpenAI-Project"), "another-project".into())].into()); + let (harness, _) = runtime_harness(&server, provider, RuntimeAuthMode::DualHeader).await; + let error = complete(&mut harness.client.new_session(), &harness) + .await + .unwrap_err(); + assert!(error.to_string().contains("openai-project does not match")); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].url.path(), "/register"); +} + +#[test_case(RuntimeAuthMode::DualHeader; "dual_header")] +#[test_case(RuntimeAuthMode::AssertionOnly; "assertion_only")] +#[tokio::test] +async fn gateway_refresh_failure_blocks_runtime_inference(mode: RuntimeAuthMode) { + skip_if_no_network!(); + let server = MockServer::start().await; + Mock::given(path("/register")) + .respond_with(registered) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/token")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "server_error"}))) + .expect(1..) + .mount(&server) + .await; + let mut provider = + ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri()))); + provider.supports_websockets = false; + provider.gateway_oauth = Some(gateway_config( + &server, + GatewayOAuthDelivery::Header { + name: "x-gateway-auth".into(), + scheme: "Bearer".into(), + }, + )); + let (harness, _) = runtime_harness(&server, provider.clone(), mode).await; + let _gateway = codex_model_provider::test_support::seed_gateway_auth( + &provider, + &harness.auth_manager, + json!({ + "access_token": "expired-gateway-token", + "refresh_token": "refresh-token", + "expires_at": 0, + }), + ); + let error = complete(&mut harness.client.new_session(), &harness) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Gateway OAuth authentication failed") + ); + let requests = server.received_requests().await.unwrap(); + assert!( + requests + .iter() + .any(|request| request.url.path() == "/token") + ); + assert!( + requests + .iter() + .all(|request| matches!(request.url.path(), "/register" | "/token")) + ); +} diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 4ba7ec70b3d2..a9a42cef45b6 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -4,6 +4,7 @@ use std::time::Instant; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClientFactory; use codex_http_client::HttpResponse; @@ -560,7 +561,7 @@ impl AuthProvider for StaticBearerAuthProvider { headers.insert(http::header::AUTHORIZATION, self.authorization.clone()); if let Some(chatgpt_account_id) = &self.chatgpt_account_id { headers.insert( - HeaderName::from_static("chatgpt-account-id"), + HeaderName::from_static(CHATGPT_ACCOUNT_ID_HEADER), chatgpt_account_id.clone(), ); } diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs index c9827577885b..94b7a88f7041 100644 --- a/codex-rs/http-client/src/lib.rs +++ b/codex-rs/http-client/src/lib.rs @@ -10,6 +10,7 @@ mod custom_ca; mod error; mod native_roots; mod network_policy; +mod openai_headers; mod outbound_proxy; mod request; mod request_builder; @@ -46,6 +47,9 @@ pub use crate::network_policy::NetworkPolicy; pub use crate::network_policy::NetworkPolicyController; pub use crate::network_policy::NetworkPolicyDenied; pub use crate::network_policy::NetworkPolicyRevision; +pub use crate::openai_headers::CHATGPT_ACCOUNT_ID_HEADER; +pub use crate::openai_headers::OPENAI_ORGANIZATION_HEADER; +pub use crate::openai_headers::OPENAI_PROJECT_HEADER; pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; pub use crate::outbound_proxy::ClientRouteClass; pub use crate::outbound_proxy::HttpClientFactory; diff --git a/codex-rs/http-client/src/openai_headers.rs b/codex-rs/http-client/src/openai_headers.rs new file mode 100644 index 000000000000..efc5e223fa6b --- /dev/null +++ b/codex-rs/http-client/src/openai_headers.rs @@ -0,0 +1,5 @@ +//! Shared OpenAI identity and resource-selection header names. + +pub const CHATGPT_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +pub const OPENAI_ORGANIZATION_HEADER: &str = "openai-organization"; +pub const OPENAI_PROJECT_HEADER: &str = "openai-project"; diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a496ee35d383..b7778b0df751 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -84,6 +84,7 @@ use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; use codex_config::ManagedAuthPolicy; use codex_config::types::AuthCredentialsStoreMode; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use codex_http_client::HttpClient; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; @@ -631,7 +632,7 @@ impl CodexAuth { match self { Self::Headers(headers) => headers .headers() - .get("chatgpt-account-id") + .get(CHATGPT_ACCOUNT_ID_HEADER) .and_then(|value| value.to_str().ok()) .filter(|account_id| !account_id.is_empty() && account_id.trim() == *account_id) .map(ToOwned::to_owned), diff --git a/codex-rs/model-provider-info/src/gateway_oauth.rs b/codex-rs/model-provider-info/src/gateway_oauth.rs index 11ff6ca6daf7..d09961a1d3b5 100644 --- a/codex-rs/model-provider-info/src/gateway_oauth.rs +++ b/codex-rs/model-provider-info/src/gateway_oauth.rs @@ -1,5 +1,6 @@ //! Secondary OAuth credentials delivered alongside the provider's primary authentication. +use codex_client::CHATGPT_ACCOUNT_ID_HEADER; use std::fmt; use codex_http_client::validate_endpoint_url; @@ -20,7 +21,7 @@ const RESERVED_GATEWAY_OAUTH_HEADERS: &[&str] = &[ "transfer-encoding", "connection", "upgrade", - "chatgpt-account-id", + CHATGPT_ACCOUNT_ID_HEADER, ]; const RESERVED_GATEWAY_OAUTH_HEADER_PREFIXES: &[&str] = &["x-codex-", "x-openai-", "sec-websocket-"]; diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 7e635a392d24..8fc8602dbc57 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -8,6 +8,8 @@ //! API provider construction applies the process-wide managed residency policy also //! used by default HTTP headers. +use codex_client::OPENAI_ORGANIZATION_HEADER; +use codex_client::OPENAI_PROJECT_HEADER; use codex_client::Provider as ApiProvider; use codex_client::RetryConfig as ApiRetryConfig; use codex_protocol::auth::AuthMode; @@ -554,10 +556,13 @@ other non-default provider fields are not supported" env_http_headers: Some( [ ( - "OpenAI-Organization".to_string(), + OPENAI_ORGANIZATION_HEADER.to_string(), "OPENAI_ORGANIZATION".to_string(), ), - ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), + ( + OPENAI_PROJECT_HEADER.to_string(), + "OPENAI_PROJECT".to_string(), + ), ] .into_iter() .collect(), diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index e72fc13cc4a4..4140bb097026 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -9,6 +9,7 @@ use codex_api::AuthError; use codex_api::AuthHeadersFuture; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use codex_login::AuthHeaders; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -103,7 +104,7 @@ impl AuthProvider for AgentIdentityAuthProvider { } if let Ok(header) = HeaderValue::from_str(self.auth.account_id()) { - let _ = headers.insert("ChatGPT-Account-ID", header); + let _ = headers.insert(CHATGPT_ACCOUNT_ID_HEADER, header); } if self.auth.is_fedramp_account() { diff --git a/codex-rs/model-provider/src/bearer_auth_provider.rs b/codex-rs/model-provider/src/bearer_auth_provider.rs index 616d122d4538..87ce528dc29b 100644 --- a/codex-rs/model-provider/src/bearer_auth_provider.rs +++ b/codex-rs/model-provider/src/bearer_auth_provider.rs @@ -1,4 +1,5 @@ use codex_api::AuthProvider; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use http::HeaderMap; use http::HeaderValue; @@ -38,7 +39,7 @@ impl AuthProvider for BearerAuthProvider { if let Some(account_id) = self.account_id.as_ref() && let Ok(header) = HeaderValue::from_str(account_id) { - let _ = headers.insert("ChatGPT-Account-ID", header); + let _ = headers.insert(CHATGPT_ACCOUNT_ID_HEADER, header); } if self.is_fedramp_account { let _ = headers.insert("X-OpenAI-Fedramp", HeaderValue::from_static("true")); diff --git a/codex-rs/model-provider/src/combined_auth.rs b/codex-rs/model-provider/src/combined_auth.rs index b9d1af39f8f6..586a8bd7e6f3 100644 --- a/codex-rs/model-provider/src/combined_auth.rs +++ b/codex-rs/model-provider/src/combined_auth.rs @@ -147,4 +147,17 @@ impl AuthProvider for CombinedAuth { Ok(request) }) } + + fn apply_auth_headers(&self, headers: HeaderMap) -> AuthHeadersFuture<'_> { + Box::pin(async move { + let mut headers = self.primary.apply_auth_headers(headers).await?; + if headers.contains_key(&self.name) { + return Err(AuthError::Build( + "gateway OAuth conflicts with request headers".into(), + )); + } + headers.insert(self.name.clone(), self.value.clone()); + Ok(headers) + }) + } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index ce3cb216f113..c3c18c4ab5ea 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -212,32 +212,45 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result> { Box::pin(async move { let auth = self.auth().await; - self.info() - .to_api_provider(auth.as_ref().map(CodexAuth::auth_mode)) + self.api_provider_for_auth(auth.as_ref()) }) } /// Resolves routing for Responses HTTP, compaction, and WebSocket handshakes. + fn responses_api_provider<'a>( + &'a self, + routing_context: &'a WorkspaceRoutingContext, + ) -> ModelProviderFuture<'a, codex_protocol::error::Result> { + Box::pin(async move { + let provider = self.api_provider().await?; + let auth = self.auth().await; + self.route_responses_api_provider(provider, auth.as_ref(), routing_context) + .await + }) + } + + /// Applies workspace routing using the credentials already selected for this request. #[expect( clippy::await_holding_invalid_type, reason = "serialize discovery and the session's first successful routing transition" )] - fn responses_api_provider<'a>( + fn route_responses_api_provider<'a>( &'a self, + mut provider: Provider, + auth: Option<&'a CodexAuth>, routing_context: &'a WorkspaceRoutingContext, ) -> ModelProviderFuture<'a, codex_protocol::error::Result> { Box::pin(async move { - let mut provider = self.api_provider().await?; let mut redirect_policy = ClientRedirectPolicy::Default; if provider_uses_first_party_auth_path(self.info()) && self.info().supports_codex_backend_routes() - && let Some(auth) = self.auth().await.filter(CodexAuth::is_chatgpt_auth) + && let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) && let Some(auth_manager) = self.auth_manager() { let mut previously_routed = routing_context.previously_routed.lock().await; if let Some(routing) = auth_manager .workspace_routing( - &auth, + auth, WorkspaceRoutingRequest { provider_base_url: provider.base_url.clone(), chatgpt_base_url: routing_context.chatgpt_base_url.clone(), @@ -259,6 +272,14 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { }) } + /// Adapt routing from the same frozen credential selected for the request. + fn api_provider_for_auth( + &self, + auth: Option<&CodexAuth>, + ) -> codex_protocol::error::Result { + self.info().to_api_provider(auth.map(CodexAuth::auth_mode)) + } + /// Returns the provider base URL that will be used at request time. fn runtime_base_url( &self, @@ -276,6 +297,14 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { }) } + /// Adds provider-owned credentials, such as gateway OAuth, to already selected primary auth. + fn compose_auth( + &self, + resolved: ResolvedProviderAuth, + ) -> ModelProviderFuture<'_, codex_protocol::error::Result> { + Box::pin(async move { Ok(resolved) }) + } + /// Returns request credentials, optionally scoped to a Codex session task. fn api_auth_for_scope( &self, @@ -487,16 +516,24 @@ impl ModelProvider for ConfiguredModelProvider { Box::pin(async move { let auth = self.auth().await; let primary = resolve_provider_auth(auth.as_ref(), &self.info)?; - Ok(compose_auth( - &self.info, - self.gateway_auth_manager.as_ref(), - ResolvedProviderAuth::new(primary), - ) - .await? - .auth) + Ok(self + .compose_auth(ResolvedProviderAuth::new(primary)) + .await? + .auth) }) } + fn compose_auth( + &self, + resolved: ResolvedProviderAuth, + ) -> ModelProviderFuture<'_, codex_protocol::error::Result> { + Box::pin(compose_auth( + &self.info, + self.gateway_auth_manager.as_ref(), + resolved, + )) + } + fn api_auth_for_scope( &self, scope: ProviderAuthScope, @@ -515,7 +552,7 @@ impl ModelProvider for ConfiguredModelProvider { let auth = self.auth().await; ResolvedProviderAuth::new(resolve_provider_auth(auth.as_ref(), &self.info)?) }; - compose_auth(&self.info, self.gateway_auth_manager.as_ref(), resolved).await + self.compose_auth(resolved).await }) } diff --git a/codex-rs/runtime-agent-instance/src/auth.rs b/codex-rs/runtime-agent-instance/src/auth.rs index e3214c6f63c9..fbccda89a313 100644 --- a/codex-rs/runtime-agent-instance/src/auth.rs +++ b/codex-rs/runtime-agent-instance/src/auth.rs @@ -4,14 +4,13 @@ use crate::ActorInfo; use crate::RegistrationError; use crate::RuntimeAgentInstance; use crate::RuntimeAuthError; -use crate::RuntimeAuthManager; use codex_api::AuthError; +use codex_api::AuthHeaderTelemetry; use codex_api::AuthHeadersFuture; use codex_api::AuthProvider; -use codex_api::AuthProviderFuture; use codex_features::RuntimeAuthMode; +use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; use codex_http_client::HttpClientFactory; -use codex_http_client::Request; use codex_login::AuthChangeState; use codex_login::CodexAuth; use http::HeaderMap; @@ -41,50 +40,12 @@ impl std::fmt::Debug for RuntimeAuth { } } -/// Auth adapter for OpenAI clients. Share its manager across requests and threads; -/// never select this provider for a third-party endpoint. -pub struct RuntimeAuthProvider { - manager: Arc, +/// Request-scoped provider: retries sign fresh assertions from the same selected runtime/credential. +struct RuntimeRequestAuth { + auth: RuntimeAuth, mode: RuntimeAuthMode, } -impl RuntimeAuthProvider { - pub fn new(manager: Arc, mode: RuntimeAuthMode) -> Self { - Self { manager, mode } - } -} - -impl AuthProvider for RuntimeAuthProvider { - fn add_auth_headers(&self, _headers: &mut HeaderMap) -> Result<(), AuthError> { - Err(AuthError::Build( - "authentication requires async header resolution".to_string(), - )) - } - - fn resolve_auth_headers(&self) -> AuthHeadersFuture<'_> { - Box::pin(async move { - let auth = self.manager.auth().await?; - auth.auth_headers(self.mode) - }) - } - - fn apply_auth(&self, mut request: Request) -> AuthProviderFuture<'_> { - Box::pin(async move { - let headers = self.resolve_auth_headers().await?; - for name in [ - AUTHORIZATION.as_str(), - AGENT_ASSERTION, - "chatgpt-account-id", - "x-openai-fedramp", - ] { - request.headers.remove(name); - } - request.headers.extend(headers); - Ok(request) - }) - } -} - impl RuntimeAuth { pub fn runtime(&self) -> &Arc { &self.runtime @@ -102,6 +63,11 @@ impl RuntimeAuth { self.change_state } + /// Use this selection for a request and its retries; select auth again for the next request. + pub fn into_auth_provider(self, mode: RuntimeAuthMode) -> codex_api::SharedAuthProvider { + Arc::new(RuntimeRequestAuth { auth: self, mode }) + } + fn auth_headers(&self, mode: RuntimeAuthMode) -> Result { let assertion = self .runtime() @@ -125,7 +91,7 @@ impl RuntimeAuth { } if let ActorInfo::Chatgpt { account_id, .. } = self.runtime().actor().info() { headers.insert( - "chatgpt-account-id", + CHATGPT_ACCOUNT_ID_HEADER, HeaderValue::from_str(account_id) .map_err(|_| AuthError::Build("invalid runtime account header".to_string()))?, ); @@ -137,6 +103,29 @@ impl RuntimeAuth { } } +impl AuthProvider for RuntimeRequestAuth { + fn add_auth_headers(&self, headers: &mut HeaderMap) -> Result<(), AuthError> { + headers.extend(self.auth.auth_headers(self.mode)?); + Ok(()) + } + + fn auth_header_telemetry(&self) -> Result { + Ok(AuthHeaderTelemetry { + attached: true, + name: Some("authorization"), + }) + } + + fn apply_auth_headers(&self, headers: HeaderMap) -> AuthHeadersFuture<'_> { + Box::pin(async move { + Ok(replace_auth_headers( + headers, + self.resolve_auth_headers().await?, + )) + }) + } +} + fn secret_header(value: &str) -> Result { let mut header = HeaderValue::from_str(value) .map_err(|_| AuthError::Build("invalid runtime authentication header".to_string()))?; @@ -173,3 +162,16 @@ impl From for AuthError { } } } + +fn replace_auth_headers(mut headers: HeaderMap, auth: HeaderMap) -> HeaderMap { + for name in [ + AUTHORIZATION.as_str(), + AGENT_ASSERTION, + CHATGPT_ACCOUNT_ID_HEADER, + "x-openai-fedramp", + ] { + headers.remove(name); + } + headers.extend(auth); + headers +} diff --git a/codex-rs/runtime-agent-instance/src/lib.rs b/codex-rs/runtime-agent-instance/src/lib.rs index db2509616233..aaf258b63daa 100644 --- a/codex-rs/runtime-agent-instance/src/lib.rs +++ b/codex-rs/runtime-agent-instance/src/lib.rs @@ -9,7 +9,6 @@ mod runtime; pub use actor::AgentHost; pub use actor::AuthenticatedActor; -pub use auth::RuntimeAuthProvider; pub use bootstrap::ActorInfo; pub use bootstrap::AgentHostAuthorization; pub use bootstrap::AuthenticatedActorAuthorization; diff --git a/codex-rs/runtime-agent-instance/src/manager.rs b/codex-rs/runtime-agent-instance/src/manager.rs index 5740228eb442..045d2c4317fb 100644 --- a/codex-rs/runtime-agent-instance/src/manager.rs +++ b/codex-rs/runtime-agent-instance/src/manager.rs @@ -43,7 +43,18 @@ enum RuntimeState { }, } +impl std::fmt::Debug for RuntimeAuthManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RuntimeAuthManager").finish_non_exhaustive() + } +} + impl RuntimeAuthManager { + /// Check that a consumer uses this owner's credential provider, not an ambient fallback. + pub fn uses_credentials(&self, credentials: &Arc) -> bool { + Arc::ptr_eq(&self.credentials, credentials) + } + pub fn new( credentials: Arc, host: AgentHostAuthorization, diff --git a/codex-rs/runtime-agent-instance/src/manager_tests.rs b/codex-rs/runtime-agent-instance/src/manager_tests.rs index e0805f5425b8..06b2d8038c7e 100644 --- a/codex-rs/runtime-agent-instance/src/manager_tests.rs +++ b/codex-rs/runtime-agent-instance/src/manager_tests.rs @@ -1,11 +1,9 @@ //! Exercise shared bootstrap against HTTP and the existing external credential-provider boundary. use super::*; -use crate::RuntimeAuthProvider; use crate::RuntimeRole; use base64::Engine; use codex_api::AuthError; -use codex_api::AuthProvider; use codex_features::RuntimeAuthMode; use codex_http_client::DestinationPolicy; use codex_http_client::HttpClientFactory; @@ -132,6 +130,24 @@ async fn concurrent_requests_share_registration_and_replacement_preserves_in_fli assert_eq!(first.auth().get_token().unwrap(), "key-a"); assert!(!format!("{first:?}").contains("key-a")); assert_eq!(replacement.auth().get_token().unwrap(), "key-b"); + assert_eq!( + first + .clone() + .into_auth_provider(RuntimeAuthMode::DualHeader) + .resolve_auth_headers() + .await + .unwrap()["authorization"], + "Bearer key-a" + ); + assert_eq!( + replacement + .clone() + .into_auth_provider(RuntimeAuthMode::DualHeader) + .resolve_auth_headers() + .await + .unwrap()["authorization"], + "Bearer key-b" + ); let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 3); assert_eq!(requests[0].body, requests[1].body); @@ -197,24 +213,28 @@ async fn policy_recovery_preserves_pending_registration() { }, format!("{}/agent/register", server.uri()).parse().unwrap(), )); - let provider = RuntimeAuthProvider::new(manager.clone(), RuntimeAuthMode::DualHeader); assert!(matches!( - provider.resolve_auth_headers().await, + manager.auth().await.map_err(AuthError::from), Err(AuthError::Transient(_)) )); policy.invalidate(); assert!(matches!( - provider.resolve_auth_headers().await, + manager.auth().await.map_err(AuthError::from), Err(AuthError::Policy(NetworkPolicyDenied::Unavailable)) )); assert_eq!(server.received_requests().await.unwrap().len(), 1); assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); - let headers = provider.resolve_auth_headers().await.unwrap(); + let selected = manager.auth().await.unwrap(); + let headers = selected + .clone() + .into_auth_provider(RuntimeAuthMode::DualHeader) + .resolve_auth_headers() + .await + .unwrap(); assert_eq!(headers["authorization"], "Bearer key-a"); assert!(headers.contains_key("x-openai-agent-assertion")); - let selected = manager.auth().await.unwrap(); let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2); assert_eq!(requests[0].body, requests[1].body); @@ -342,13 +362,24 @@ async fn request_auth_replaces_stale_headers_and_signs_each_attempt() { .mount(&server) .await; let (manager, _, _) = setup(&server, initial.clone(), initial.clone()).await; - let provider = RuntimeAuthProvider::new(manager.clone(), mode); + let selected = manager.auth().await.unwrap(); + let provider = selected.clone().into_auth_provider(mode); + assert!( + codex_api::auth_header_telemetry(provider.as_ref()) + .unwrap() + .attached + ); let mut previous_assertion = None; for _ in 0..2 { let mut request = codex_http_client::Request::new( Method::POST, format!("{}/responses", server.uri()), ); + request + .headers + .insert("openai-project", HeaderValue::from_static("project-a")); + let mut sync_headers = request.headers.clone(); + provider.add_auth_headers(&mut sync_headers).unwrap(); for name in [ "authorization", "x-openai-agent-assertion", @@ -359,49 +390,47 @@ async fn request_auth_replaces_stale_headers_and_signs_each_attempt() { .headers .insert(name, HeaderValue::from_static("stale")); } - request - .headers - .insert("openai-project", HeaderValue::from_static("project-a")); - let mut headers = provider.apply_auth(request).await.unwrap().headers; - let mut expected = HeaderMap::new(); - expected.insert("openai-project", HeaderValue::from_static("project-a")); - if initial.is_chatgpt_auth() { - expected.insert( - "chatgpt-account-id", - HeaderValue::from_static("workspace-a"), - ); - } - let assertion = match mode { - RuntimeAuthMode::DualHeader => { - let bearer = headers.remove(AUTHORIZATION).unwrap(); - assert!(bearer.is_sensitive()); - assert_eq!(bearer, format!("Bearer {}", initial.get_token().unwrap())); - headers.remove("x-openai-agent-assertion").unwrap() - } - RuntimeAuthMode::AssertionOnly => headers.remove(AUTHORIZATION).unwrap(), - }; - assert!(assertion.is_sensitive()); - assert_eq!(headers, expected); - assert_ne!(previous_assertion.as_ref(), Some(&assertion)); - let assertion_text = assertion.to_str().unwrap(); - let assertion_text = match mode { - RuntimeAuthMode::DualHeader => assertion_text, - RuntimeAuthMode::AssertionOnly => { - assertion_text.strip_prefix("AgentAssertion ").unwrap() + for mut headers in [ + sync_headers, + provider.apply_auth(request).await.unwrap().headers, + ] { + let mut expected = HeaderMap::new(); + expected.insert("openai-project", HeaderValue::from_static("project-a")); + if initial.is_chatgpt_auth() { + expected.insert( + "chatgpt-account-id", + HeaderValue::from_static("workspace-a"), + ); } - }; - let segments: Vec<_> = assertion_text.split('.').collect(); - let claims: Value = serde_json::from_slice( - &base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(segments[2]) - .unwrap(), - ) - .unwrap(); - assert_eq!( - claims["runtime_agent_instance"], - manager.auth().await.unwrap().runtime().id() - ); - previous_assertion = Some(assertion); + let assertion = match mode { + RuntimeAuthMode::DualHeader => { + let bearer = headers.remove(AUTHORIZATION).unwrap(); + assert!(bearer.is_sensitive()); + assert_eq!(bearer, format!("Bearer {}", initial.get_token().unwrap())); + headers.remove("x-openai-agent-assertion").unwrap() + } + RuntimeAuthMode::AssertionOnly => headers.remove(AUTHORIZATION).unwrap(), + }; + assert!(assertion.is_sensitive()); + assert_eq!(headers, expected); + assert_ne!(previous_assertion.as_ref(), Some(&assertion)); + let assertion_text = assertion.to_str().unwrap(); + let assertion_text = match mode { + RuntimeAuthMode::DualHeader => assertion_text, + RuntimeAuthMode::AssertionOnly => { + assertion_text.strip_prefix("AgentAssertion ").unwrap() + } + }; + let segments: Vec<_> = assertion_text.split('.').collect(); + let claims: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segments[2]) + .unwrap(), + ) + .unwrap(); + assert_eq!(claims["runtime_agent_instance"], selected.runtime().id()); + previous_assertion = Some(assertion); + } } assert_eq!(server.received_requests().await.unwrap().len(), 1); } @@ -409,7 +438,7 @@ async fn request_auth_replaces_stale_headers_and_signs_each_attempt() { } #[tokio::test] -async fn header_auth_uses_refreshed_credentials_without_replacing_the_runtime() { +async fn new_request_uses_refreshed_credentials_without_replacing_the_runtime() { let server = MockServer::start().await; Mock::given(method("POST")) .respond_with(registered) @@ -417,26 +446,35 @@ async fn header_auth_uses_refreshed_credentials_without_replacing_the_runtime() .await; let initial = oauth(Some("login-a"), "old"); let (manager, source, _) = setup(&server, initial.clone(), initial).await; - let provider = RuntimeAuthProvider::new(manager.clone(), RuntimeAuthMode::DualHeader); - let first = provider.resolve_auth_headers().await.unwrap(); let runtime = manager.auth().await.unwrap(); + let provider = runtime + .clone() + .into_auth_provider(RuntimeAuthMode::DualHeader); + let first = provider.resolve_auth_headers().await.unwrap(); let refreshed = oauth(Some("login-a"), "new"); *source.current.write().unwrap() = refreshed.clone(); - let next = provider.resolve_auth_headers().await.unwrap(); + let next_runtime = manager.auth().await.unwrap(); + let next = next_runtime + .clone() + .into_auth_provider(RuntimeAuthMode::DualHeader) + .resolve_auth_headers() + .await + .unwrap(); assert_eq!( next[AUTHORIZATION], format!("Bearer {}", refreshed.get_token().unwrap()) ); assert_ne!(first[AUTHORIZATION], next[AUTHORIZATION]); - assert!(Arc::ptr_eq( - runtime.runtime(), - manager.auth().await.unwrap().runtime() - )); + assert_eq!( + provider.resolve_auth_headers().await.unwrap()[AUTHORIZATION], + first[AUTHORIZATION] + ); + assert!(Arc::ptr_eq(runtime.runtime(), next_runtime.runtime())); assert_eq!(server.received_requests().await.unwrap().len(), 1); } #[tokio::test] -async fn registration_failure_never_returns_credential_only_auth() { +async fn registration_failure_reports_retryability() { for status in [403, 404, 429, 503] { let server = MockServer::start().await; Mock::given(method("POST")) @@ -445,8 +483,7 @@ async fn registration_failure_never_returns_credential_only_auth() { .await; let initial = CodexAuth::from_api_key("key-a"); let (manager, _, _) = setup(&server, initial.clone(), initial).await; - let provider = RuntimeAuthProvider::new(manager, RuntimeAuthMode::DualHeader); - let error = provider.resolve_auth_headers().await.unwrap_err(); + let error = AuthError::from(manager.auth().await.unwrap_err()); assert_eq!( matches!(error, AuthError::Transient(_)), status == 429 || status == 503 From a797dd7c6f9b201818a18d23921eb3d1ef3c1314 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 23 Oct 2026 19:40:02 +0000 Subject: [PATCH 009/287] Expose lifecycle timestamps on app-server thread items (#57172) ## What changed - Add nullable `startedAtMs` and `completedAtMs` fields to `ThreadItem` in live notifications and turn history. Values are Unix milliseconds when recorded. - Preserve recorded item times through history reconstruction, pagination, and resume, including command start times recorded during approval. - Separate the Rust item payload into `ThreadItemPayload` while keeping the JSON payload flat, and update consumers, schemas, and generated TypeScript and Python types. ## Testing Extend coverage for timestamp serialization, history reconstruction, paginated reads and resume, and persisted Python SDK items. GitOrigin-RevId: ecbcee95af52db7c807b46a6e9deb30e5fba1f80 --- codex-rs/analytics/src/client.rs | 29 +- .../analytics/src/client_product_tests.rs | 29 +- codex-rs/analytics/src/reducer.rs | 184 +++-- .../src/tests/suite/reducer_ordering_tests.rs | 38 +- .../src/tests/suite/threads_tests.rs | 25 +- .../analytics/src/tests/suite/tools_tests.rs | 137 ++-- .../src/tests/suite/turns/events_tests.rs | 41 +- codex-rs/analytics/src/tests/support.rs | 33 +- codex-rs/app-server-client/src/lib.rs | 33 +- .../schema/json/ServerNotification.json | 24 +- .../codex_app_server_protocol.schemas.json | 24 +- .../codex_app_server_protocol.v2.schemas.json | 24 +- .../json/v2/ItemCompletedNotification.json | 24 +- .../json/v2/ItemStartedNotification.json | 24 +- .../schema/json/v2/ReviewStartResponse.json | 24 +- .../schema/json/v2/ThreadForkResponse.json | 24 +- .../json/v2/ThreadItemsListResponse.json | 24 +- .../schema/json/v2/ThreadListResponse.json | 24 +- .../json/v2/ThreadMetadataUpdateResponse.json | 24 +- .../schema/json/v2/ThreadReadResponse.json | 24 +- .../schema/json/v2/ThreadResumeResponse.json | 24 +- .../schema/json/v2/ThreadRevertResponse.json | 24 +- .../schema/json/v2/ThreadStartResponse.json | 24 +- .../json/v2/ThreadStartedNotification.json | 24 +- .../json/v2/ThreadTurnsListResponse.json | 24 +- .../json/v2/ThreadUnarchiveResponse.json | 24 +- .../json/v2/TurnCompletedNotification.json | 24 +- .../schema/json/v2/TurnStartResponse.json | 24 +- .../json/v2/TurnStartedNotification.json | 24 +- .../app-server-exports-experimental.json.zst | Bin 168306 -> 168429 bytes .../app-server-exports-stable.json.zst | Bin 161018 -> 161065 bytes .../schema/typescript/v2/ThreadItem.ts | 15 +- .../src/protocol/event_mapping.rs | 166 ++-- .../src/protocol/item_builders.rs | 28 +- .../src/protocol/item_builders_tests.rs | 7 +- .../src/protocol/thread_history.rs | 769 ++++++++++++------ .../src/protocol/thread_history_projection.rs | 4 +- .../thread_history_projection_tests.rs | 26 +- .../src/protocol/turn_items_view.rs | 16 +- .../src/protocol/v2/item.rs | 210 +++-- .../src/protocol/v2/tests.rs | 78 +- .../src/protocol/v2/thread.rs | 10 + .../src/protocol/v2/thread_data.rs | 10 + codex-rs/app-server-test-client/src/lib.rs | 27 +- codex-rs/app-server/README.md | 5 + .../app-server/src/bespoke_event_handling.rs | 218 +++-- codex-rs/app-server/src/in_process.rs | 22 +- codex-rs/app-server/src/notification_media.rs | 52 +- codex-rs/app-server/src/request_processors.rs | 2 +- .../request_processors/thread_lifecycle.rs | 1 + .../request_processors/thread_processor.rs | 33 +- .../thread_processor_tests.rs | 5 +- .../request_processors/thread_read_state.rs | 8 +- .../thread_resume_redaction.rs | 36 +- .../src/request_processors/turn_processor.rs | 5 +- codex-rs/app-server/src/thread_state.rs | 25 +- .../app-server/tests/suite/v2/analytics.rs | 14 +- .../app-server/tests/suite/v2/compaction.rs | 14 +- .../v2/config_requirements_exec_tests.rs | 6 +- .../tests/suite/v2/daybreak_access.rs | 6 +- .../tests/suite/v2/dynamic_tools.rs | 28 +- .../tests/suite/v2/external_agent_config.rs | 32 +- .../suite/v2/external_agent_import_sync.rs | 13 +- .../app-server/tests/suite/v2/guardian_v2.rs | 6 +- .../tests/suite/v2/imagegen_extension.rs | 42 +- .../app-server/tests/suite/v2/mcp_tool.rs | 32 +- .../app-server/tests/suite/v2/plan_item.rs | 18 +- .../tests/suite/v2/realtime_conversation.rs | 30 +- codex-rs/app-server/tests/suite/v2/review.rs | 23 +- .../tests/suite/v2/safety_check_downgrade.rs | 16 +- codex-rs/app-server/tests/suite/v2/sleep.rs | 33 +- .../app-server/tests/suite/v2/thread_fork.rs | 14 +- .../tests/suite/v2/thread_inject_items.rs | 13 +- .../app-server/tests/suite/v2/thread_queue.rs | 20 +- .../app-server/tests/suite/v2/thread_read.rs | 129 ++- .../tests/suite/v2/thread_resume.rs | 56 +- .../tests/suite/v2/thread_shell_command.rs | 36 +- .../tests/suite/v2/thread_timeline.rs | 6 +- .../tests/suite/v2/thread_unsubscribe.rs | 5 +- .../app-server/tests/suite/v2/turn_start.rs | 125 +-- .../app-server/tests/suite/v2/turn_steer.rs | 6 +- .../app-server/tests/suite/v2/view_image.rs | 5 +- .../app-server/tests/suite/v2/web_search.rs | 21 +- codex-rs/core/src/session/mod.rs | 8 + codex-rs/core/tests/suite/guardian_retry.rs | 12 +- .../core/tests/suite/realtime_conversation.rs | 2 +- .../unified_exec_launch_failure_tests.rs | 9 +- .../src/event_processor_with_human_output.rs | 46 +- ...event_processor_with_human_output_tests.rs | 21 +- .../src/event_processor_with_jsonl_output.rs | 48 +- ...event_processor_with_jsonl_output_tests.rs | 26 +- codex-rs/exec/src/lib.rs | 3 +- codex-rs/exec/src/lib_tests.rs | 42 +- .../tests/event_processor_with_json_output.rs | 371 +++++---- .../src/sessions/export.rs | 12 +- codex-rs/protocol/src/protocol.rs | 28 + .../thread-store/src/local/thread_history.rs | 57 +- .../src/local/thread_history/realtime.rs | 34 +- .../src/local/thread_history/search.rs | 53 +- .../local/thread_history_materialization.rs | 2 +- .../thread_history_materialization_tests.rs | 14 +- codex-rs/thread-store/src/types.rs | 2 +- codex-rs/tui/src/app.rs | 26 +- codex-rs/tui/src/app/activity_groups_tests.rs | 26 +- codex-rs/tui/src/app/agent_status_feed.rs | 48 +- .../tui/src/app/agent_status_feed_tests.rs | 52 +- .../tui/src/app/agents_overview_details.rs | 8 +- codex-rs/tui/src/app/agents_overview_tests.rs | 42 +- .../tui/src/app/agents_overview_threads.rs | 11 +- codex-rs/tui/src/app/app_server_events.rs | 6 +- codex-rs/tui/src/app/history_completion.rs | 6 +- .../tui/src/app/history_completion_tests.rs | 31 +- codex-rs/tui/src/app/history_pagination.rs | 16 +- .../tui/src/app/history_pagination_tests.rs | 25 +- codex-rs/tui/src/app/native_history_tests.rs | 2 +- .../src/app/owned_transcript_input_tests.rs | 13 +- .../tui/src/app/pending_interactive_replay.rs | 8 +- codex-rs/tui/src/app/realtime_delivery.rs | 31 +- codex-rs/tui/src/app/reasoning_replay.rs | 8 +- codex-rs/tui/src/app/replay_filter.rs | 4 +- codex-rs/tui/src/app/safety_buffering.rs | 7 +- codex-rs/tui/src/app/tests.rs | 206 ++--- .../src/app/tests/active_reconnect_tests.rs | 2 +- .../app/tests/browsing_pagination_tests.rs | 2 +- codex-rs/tui/src/app/tests/buffered_replay.rs | 233 +++--- .../src/app/tests/history_hydration_tests.rs | 6 +- .../app/tests/navigation_reconnect_tests.rs | 2 +- .../app/tests/pagination_completion_tests.rs | 4 +- .../tui/src/app/tests/patch_approval_tests.rs | 45 +- .../tui/src/app/tests/realtime_requests.rs | 99 ++- .../src/app/tests/reasoning_resume_tests.rs | 29 +- .../tui/src/app/tests/safety_buffering.rs | 8 +- .../app/tests/session_lifecycle_requests.rs | 12 +- .../tui/src/app/tests/startup_frame_tests.rs | 2 +- .../src/app/tests/streaming_interleave_e2e.rs | 8 +- codex-rs/tui/src/app/tests/thread_usage.rs | 2 +- .../app/tests/unavailable_commands_tests.rs | 2 +- .../tests/user_verification_routes_tests.rs | 29 +- codex-rs/tui/src/app/thread_events.rs | 169 ++-- codex-rs/tui/src/app/thread_routing.rs | 10 +- codex-rs/tui/src/app/thread_title.rs | 13 +- codex-rs/tui/src/app/thread_title_tests.rs | 27 +- codex-rs/tui/src/app/transcript_export.rs | 20 +- .../tui/src/app/transcript_export_tests.rs | 17 +- codex-rs/tui/src/app/turn_tips.rs | 12 +- codex-rs/tui/src/app_backtrack.rs | 78 +- codex-rs/tui/src/app_server_session.rs | 39 +- .../tui/src/app_server_session/history.rs | 13 +- codex-rs/tui/src/chatwidget.rs | 2 +- .../tui/src/chatwidget/command_lifecycle.rs | 16 +- .../tui/src/chatwidget/dynamic_activity.rs | 8 +- codex-rs/tui/src/chatwidget/interrupts.rs | 16 +- codex-rs/tui/src/chatwidget/protocol.rs | 53 +- codex-rs/tui/src/chatwidget/realtime.rs | 34 +- .../tui/src/chatwidget/realtime_navigation.rs | 2 +- codex-rs/tui/src/chatwidget/realtime_tests.rs | 43 +- .../realtime_tests/caption_replay.rs | 39 +- .../realtime_tests/handoff_privacy.rs | 8 +- .../src/chatwidget/realtime_tests/handoffs.rs | 2 +- .../realtime_tests/speech_recovery.rs | 8 +- codex-rs/tui/src/chatwidget/replay.rs | 108 +-- codex-rs/tui/src/chatwidget/session_flow.rs | 8 +- codex-rs/tui/src/chatwidget/tests.rs | 2 +- .../tui/src/chatwidget/tests/app_server.rs | 193 ++--- .../src/chatwidget/tests/compaction_tests.rs | 25 +- .../tests/completion_styling_tests.rs | 19 +- .../tests/computer_activity_tests.rs | 8 +- .../tests/dynamic_activity_tests.rs | 4 +- .../tui/src/chatwidget/tests/exec_flow.rs | 49 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 180 ++-- .../chatwidget/tests/history_projection.rs | 52 +- .../src/chatwidget/tests/history_replay.rs | 104 +-- .../tui/src/chatwidget/tests/plan_mode.rs | 37 +- .../tests/question_notifications_tests.rs | 13 +- .../src/chatwidget/tests/questions_tests.rs | 28 +- .../tests/reasoning_status_tests.rs | 17 +- .../tui/src/chatwidget/tests/review_mode.rs | 42 +- .../src/chatwidget/tests/slash_commands.rs | 26 +- .../src/chatwidget/tests/status_and_layout.rs | 4 +- .../tests/subagent_activity_tests.rs | 25 +- .../chatwidget/tests/tool_activity_tests.rs | 4 +- codex-rs/tui/src/chatwidget/tool_lifecycle.rs | 54 +- .../tui/src/clipboard_copy/worker_tests.rs | 13 +- codex-rs/tui/src/dynamic_tools.rs | 91 +-- codex-rs/tui/src/history_cell/dynamic.rs | 10 +- .../tui/src/history_cell/dynamic_tests.rs | 4 +- codex-rs/tui/src/lib_tests.rs | 5 +- codex-rs/tui/src/multi_agents.rs | 40 +- .../tui/src/pager_overlay/transcript_tests.rs | 2 +- codex-rs/tui/src/resume_picker.rs | 21 +- .../src/resume_picker_transcript_preview.rs | 12 +- .../resume_picker_transcript_preview_tests.rs | 4 +- .../tui/src/temporary_structured_request.rs | 4 +- .../src/temporary_structured_request_tests.rs | 15 +- codex-rs/tui/src/thread_transcript.rs | 26 +- .../src/thread_transcript/computer_groups.rs | 21 +- .../computer_groups_tests.rs | 23 +- .../thread_transcript/exploration_groups.rs | 13 +- .../tui/src/thread_transcript/other_items.rs | 54 +- .../thread_transcript/other_items_tests.rs | 22 +- codex-rs/tui/src/thread_transcript/tools.rs | 18 +- .../tui/src/thread_transcript/tools_tests.rs | 20 +- .../src/transcript_view/mutations_tests.rs | 11 +- sdk/python/scripts/update_sdk_artifacts.py | 7 + .../src/openai_codex/generated/v2_all.py | 289 ++++++- sdk/python/tests/test_app_server_inputs.py | 13 +- 206 files changed, 4689 insertions(+), 3087 deletions(-) diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index aef8abef1324..b0a8705b1bf4 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -436,22 +436,29 @@ impl AnalyticsEventsClient { completed_at_ms: i64, ) { let thread_id = item.sender_thread_id.to_string(); - let completed_item = TurnItem::CollabAgentToolCall(item.clone()).into(); + let completed_item = codex_app_server_protocol::ThreadItemPayload::from( + TurnItem::CollabAgentToolCall(item.clone()), + ); item.status = CollabAgentToolCallStatus::InProgress; - self.track_notification(&ServerNotification::ItemStarted(ItemStartedNotification { - item: TurnItem::CollabAgentToolCall(item).into(), - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - started_at_ms, - })); + self.track_notification(&ServerNotification::ItemStarted( + ItemStartedNotification::new( + thread_id.clone(), + turn_id.clone(), + codex_app_server_protocol::ThreadItemPayload::from(TurnItem::CollabAgentToolCall( + item, + )), + started_at_ms, + ), + )); self.track_notification(&ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: completed_item, + ItemCompletedNotification::new( thread_id, turn_id, + completed_item, + Some(started_at_ms), completed_at_ms, - }, + ), )); } @@ -935,7 +942,7 @@ fn session_event_to_analytics_notification( match ¬ification { ServerNotification::ItemStarted(ItemStartedNotification { item, .. }) | ServerNotification::ItemCompleted(ItemCompletedNotification { item, .. }) => { - tracked_tool_item_id(item)?; + tracked_tool_item_id(&item.payload)?; } _ => {} } diff --git a/codex-rs/analytics/src/client_product_tests.rs b/codex-rs/analytics/src/client_product_tests.rs index 029dea7fd103..641510cd55fd 100644 --- a/codex-rs/analytics/src/client_product_tests.rs +++ b/codex-rs/analytics/src/client_product_tests.rs @@ -22,7 +22,7 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnStartedNotification; use codex_login::AuthManager; use codex_protocol::ThreadId; @@ -238,11 +238,10 @@ async fn buffered_tool_events_preserve_attribution_or_drop_it_on_queue_overflow( turn: started.turn, })); // Collaborator completions wait in the reducer for subsequent sampling evidence. - let completed = ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 2, - item: ThreadItem::CollabAgentToolCall { + let completed = ItemCompletedNotification::new( + thread_id.to_string(), + "turn-1".to_string(), + ThreadItemPayload::CollabAgentToolCall { id: "item".to_string(), tool: CollabAgentTool::SendMessage, status: CollabAgentToolCallStatus::Failed, @@ -253,13 +252,17 @@ async fn buffered_tool_events_preserve_attribution_or_drop_it_on_queue_overflow( reasoning_effort: None, agents_states: Default::default(), }, - }; - client.track_notification(&ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 1, - item: completed.item.clone(), - })); + /*started_at_ms*/ None, + /*completed_at_ms*/ 2, + ); + client.track_notification(&ServerNotification::ItemStarted( + ItemStartedNotification::new( + thread_id.to_string(), + "turn-1".to_string(), + completed.item.payload.clone(), + /*started_at_ms*/ 1, + ), + )); client.track_notification(&ServerNotification::ItemCompleted(completed)); let measurement = PluginMeasurementsInput { thread_id: thread_id.to_string(), diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index cef0a71216d1..7d0bf4058ee3 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -154,7 +154,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_app_server_protocol::SubAgentActivityKind; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_app_server_protocol::WebSearchAction; @@ -535,36 +535,36 @@ struct TurnToolCounts { } impl TurnToolCounts { - fn record(&mut self, item: &ThreadItem) { + fn record(&mut self, item: &ThreadItemPayload) { match item { - ThreadItem::CommandExecution { .. } => self.shell_command += 1, - ThreadItem::FileChange { .. } => self.file_change += 1, - ThreadItem::McpToolCall { .. } => self.mcp_tool_call += 1, - ThreadItem::DynamicToolCall { .. } => self.dynamic_tool_call += 1, - ThreadItem::SubAgentActivity { + ThreadItemPayload::CommandExecution { .. } => self.shell_command += 1, + ThreadItemPayload::FileChange { .. } => self.file_change += 1, + ThreadItemPayload::McpToolCall { .. } => self.mcp_tool_call += 1, + ThreadItemPayload::DynamicToolCall { .. } => self.dynamic_tool_call += 1, + ThreadItemPayload::SubAgentActivity { kind: SubAgentActivityKind::Completed, .. } => return, - ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::SubAgentActivity { id, .. } => { + ThreadItemPayload::CollabAgentToolCall { id, .. } + | ThreadItemPayload::SubAgentActivity { id, .. } => { if !self.subagent_tool_call_ids.insert(id.clone()) { return; } self.subagent_tool_call += 1; } - ThreadItem::WebSearch(_) => self.web_search += 1, - ThreadItem::ImageGeneration(_) => self.image_generation += 1, - ThreadItem::UserMessage { .. } - | ThreadItem::HookPrompt { .. } - | ThreadItem::AgentMessage { .. } - | ThreadItem::FunctionCallOutput { .. } - | ThreadItem::Plan { .. } - | ThreadItem::Reasoning { .. } - | ThreadItem::ImageView { .. } - | ThreadItem::Sleep(_) - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } - | ThreadItem::ContextCompaction { .. } => return, + ThreadItemPayload::WebSearch(_) => self.web_search += 1, + ThreadItemPayload::ImageGeneration(_) => self.image_generation += 1, + ThreadItemPayload::UserMessage { .. } + | ThreadItemPayload::HookPrompt { .. } + | ThreadItemPayload::AgentMessage { .. } + | ThreadItemPayload::FunctionCallOutput { .. } + | ThreadItemPayload::Plan { .. } + | ThreadItemPayload::Reasoning { .. } + | ThreadItemPayload::ImageView { .. } + | ThreadItemPayload::Sleep(_) + | ThreadItemPayload::EnteredReviewMode { .. } + | ThreadItemPayload::ExitedReviewMode { .. } + | ThreadItemPayload::ContextCompaction { .. } => return, } self.total += 1; } @@ -2060,15 +2060,17 @@ impl AnalyticsReducer { })); } ServerNotification::ItemStarted(notification) => { - let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + let Some(item_id) = tracked_tool_item_id(¬ification.item.payload) else { return; }; let Some(started_at_ms) = option_i64_to_u64(Some(notification.started_at_ms)) else { return; }; - let model_context = match ¬ification.item { - ThreadItem::CommandExecution { model_context, .. } => model_context.clone(), + let model_context = match ¬ification.item.payload { + ThreadItemPayload::CommandExecution { model_context, .. } => { + model_context.clone() + } _ => None, }; self.turn_state(notification.turn_id.clone()) @@ -2079,7 +2081,7 @@ impl AnalyticsReducer { turn_id: notification.turn_id.clone(), item_id: item_id.to_string(), }; - let cua_identity = cua_mcp_tool_identity(¬ification.item); + let cua_identity = cua_mcp_tool_identity(¬ification.item.payload); if cua_identity.is_some() && self .turns @@ -2148,7 +2150,10 @@ impl AnalyticsReducer { )); } ServerNotification::ItemCompleted(notification) => { - if matches!(notification.item, ThreadItem::SubAgentActivity { .. }) { + if matches!( + notification.item.payload, + ThreadItemPayload::SubAgentActivity { .. } + ) { let Some(turn_state) = self.turns.get_mut(¬ification.turn_id) else { tracing::warn!( thread_id = %notification.thread_id, @@ -2157,10 +2162,10 @@ impl AnalyticsReducer { ); return; }; - turn_state.tool_counts.record(¬ification.item); + turn_state.tool_counts.record(¬ification.item.payload); return; } - let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + let Some(item_id) = tracked_tool_item_id(¬ification.item.payload) else { return; }; let key = ToolItemKey { @@ -2168,7 +2173,7 @@ impl AnalyticsReducer { turn_id: notification.turn_id.clone(), item_id: item_id.to_string(), }; - let is_cua = cua_mcp_tool_identity(¬ification.item).is_some(); + let is_cua = cua_mcp_tool_identity(¬ification.item.payload).is_some(); if is_cua && self .turns @@ -2177,18 +2182,20 @@ impl AnalyticsReducer { { return; } - let elicitation_type = - if matches!(notification.item, ThreadItem::McpToolCall { .. }) { - // Producers enqueue known classifications before completion on - // the same analytics queue, so completed keys need no history. - self.pending_mcp_tool_elicitations - .iter() - .position(|(pending, _)| pending == &key) - .and_then(|index| self.pending_mcp_tool_elicitations.remove(index)) - .map(|(_, elicitation_type)| elicitation_type) - } else { - None - }; + let elicitation_type = if matches!( + notification.item.payload, + ThreadItemPayload::McpToolCall { .. } + ) { + // Producers enqueue known classifications before completion on + // the same analytics queue, so completed keys need no history. + self.pending_mcp_tool_elicitations + .iter() + .position(|(pending, _)| pending == &key) + .and_then(|index| self.pending_mcp_tool_elicitations.remove(index)) + .map(|(_, elicitation_type)| elicitation_type) + } else { + None + }; let Some(turn_state) = self.turns.get_mut(¬ification.turn_id) else { tracing::warn!( thread_id = %notification.thread_id, @@ -2198,7 +2205,7 @@ impl AnalyticsReducer { ); return; }; - turn_state.tool_counts.record(¬ification.item); + turn_state.tool_counts.record(¬ification.item.payload); let Some((started_at_ms, model_context)) = self.tool_items_started_at_ms.remove(&key) else { @@ -2222,7 +2229,7 @@ impl AnalyticsReducer { if let Some(mut event) = tool_item_event(ToolItemEventInput { thread_id: ¬ification.thread_id, turn_id: ¬ification.turn_id, - item: ¬ification.item, + item: ¬ification.item.payload, model_context: model_context.as_ref(), started_at_ms, completed_at_ms, @@ -2245,12 +2252,14 @@ impl AnalyticsReducer { .and_then(|config| config.turn_metadata.root_turn_id()); // Fast collaborator tools can complete before their sampling response. // Keep the event until that response can supply its originating ID. - let emission = - if matches!(¬ification.item, ThreadItem::CollabAgentToolCall { .. }) { - ToolEventEmission::AwaitResponse - } else { - ToolEventEmission::ImmediateUnlessCorrelated - }; + let emission = if matches!( + ¬ification.item.payload, + ThreadItemPayload::CollabAgentToolCall { .. } + ) { + ToolEventEmission::AwaitResponse + } else { + ToolEventEmission::ImmediateUnlessCorrelated + }; self.record_tool_event( ¬ification.thread_id, ¬ification.turn_id, @@ -2903,32 +2912,34 @@ fn warn_missing_analytics_context( ); } -pub(crate) fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { +pub(crate) fn tracked_tool_item_id(item: &ThreadItemPayload) -> Option<&str> { match item { - ThreadItem::CommandExecution { id, .. } - | ThreadItem::FileChange { id, .. } - | ThreadItem::McpToolCall { id, .. } - | ThreadItem::DynamicToolCall { id, .. } - | ThreadItem::CollabAgentToolCall { id, .. } => Some(id), - ThreadItem::WebSearch(item) => Some(&item.id), - ThreadItem::ImageGeneration(item) => Some(&item.id), - ThreadItem::UserMessage { .. } - | ThreadItem::HookPrompt { .. } - | ThreadItem::AgentMessage { .. } - | ThreadItem::FunctionCallOutput { .. } - | ThreadItem::Plan { .. } - | ThreadItem::Reasoning { .. } - | ThreadItem::SubAgentActivity { .. } - | ThreadItem::ImageView { .. } - | ThreadItem::Sleep(_) - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } - | ThreadItem::ContextCompaction { .. } => None, + ThreadItemPayload::CommandExecution { id, .. } + | ThreadItemPayload::FileChange { id, .. } + | ThreadItemPayload::McpToolCall { id, .. } + | ThreadItemPayload::DynamicToolCall { id, .. } + | ThreadItemPayload::CollabAgentToolCall { id, .. } => Some(id), + ThreadItemPayload::WebSearch(item) => Some(&item.id), + ThreadItemPayload::ImageGeneration(item) => Some(&item.id), + ThreadItemPayload::UserMessage { .. } + | ThreadItemPayload::HookPrompt { .. } + | ThreadItemPayload::AgentMessage { .. } + | ThreadItemPayload::FunctionCallOutput { .. } + | ThreadItemPayload::Plan { .. } + | ThreadItemPayload::Reasoning { .. } + | ThreadItemPayload::SubAgentActivity { .. } + | ThreadItemPayload::ImageView { .. } + | ThreadItemPayload::Sleep(_) + | ThreadItemPayload::EnteredReviewMode { .. } + | ThreadItemPayload::ExitedReviewMode { .. } + | ThreadItemPayload::ContextCompaction { .. } => None, } } -fn cua_mcp_tool_identity(item: &ThreadItem) -> Option<(&str, &str, Option<&str>, Option<&str>)> { - let ThreadItem::McpToolCall { +fn cua_mcp_tool_identity( + item: &ThreadItemPayload, +) -> Option<(&str, &str, Option<&str>, Option<&str>)> { + let ThreadItemPayload::McpToolCall { server, tool, plugin_id, @@ -3037,7 +3048,7 @@ fn item_review_summary_key(pending_review: &PendingReviewState) -> Option { thread_id: &'a str, turn_id: &'a str, - item: &'a ThreadItem, + item: &'a ThreadItemPayload, model_context: Option<&'a ModelInvocationContext>, started_at_ms: u64, completed_at_ms: u64, @@ -3063,7 +3074,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { elicitation_type, } = input; match item { - ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { id, sandbox_type, plugin_id, @@ -3122,7 +3133,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, )) } - ThreadItem::FileChange { + ThreadItemPayload::FileChange { id, changes, status, @@ -3160,7 +3171,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, })) } - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { id, server, tool, @@ -3209,7 +3220,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, )) } - ThreadItem::DynamicToolCall { + ThreadItemPayload::DynamicToolCall { id, tool, status, @@ -3256,7 +3267,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, )) } - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -3323,7 +3334,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, )) } - ThreadItem::WebSearch(item) => { + ThreadItemPayload::WebSearch(item) => { let base = tool_item_base( thread_id, turn_id, @@ -3353,7 +3364,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { }, })) } - ThreadItem::ImageGeneration(item) => { + ThreadItemPayload::ImageGeneration(item) => { let (terminal_status, failure_kind) = image_generation_outcome(item.status.as_str()); let base = tool_item_base( thread_id, @@ -4235,11 +4246,10 @@ mod tests { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: keys[2].thread_id.clone(), - turn_id: keys[2].turn_id.clone(), - completed_at_ms: 1_000, - item: ThreadItem::McpToolCall { + ItemCompletedNotification::new( + keys[2].thread_id.clone(), + keys[2].turn_id.clone(), + ThreadItemPayload::McpToolCall { id: keys[2].item_id.clone(), server: "server".to_string(), tool: "tool".to_string(), @@ -4254,7 +4264,9 @@ mod tests { error: None, duration_ms: None, }, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_000, + ), ))), &mut events, ) diff --git a/codex-rs/analytics/src/tests/suite/reducer_ordering_tests.rs b/codex-rs/analytics/src/tests/suite/reducer_ordering_tests.rs index 1275543ce45f..d6e8c65f2a92 100644 --- a/codex-rs/analytics/src/tests/suite/reducer_ordering_tests.rs +++ b/codex-rs/analytics/src/tests/suite/reducer_ordering_tests.rs @@ -308,16 +308,16 @@ async fn completed_background_tool_item_emits_after_turn_event() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - started_at_ms: 998, - item: sample_command_execution_item( + ItemStartedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), + sample_command_execution_item( CommandExecutionStatus::InProgress, /*exit_code*/ None, /*duration_ms*/ None, ), - }, + /*started_at_ms*/ 998, + ), ))), &mut out, ) @@ -343,16 +343,17 @@ async fn completed_background_tool_item_emits_after_turn_event() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - completed_at_ms: 1_000, - item: sample_command_execution_item( + ItemCompletedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), + sample_command_execution_item( CommandExecutionStatus::Completed, Some(0), Some(1), ), - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_000, + ), ))), &mut out, ) @@ -430,16 +431,17 @@ async fn item_completed_without_turn_state_does_not_create_turn_state() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - completed_at_ms: 1_000, - item: sample_command_execution_item( + ItemCompletedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), + sample_command_execution_item( CommandExecutionStatus::Completed, Some(0), Some(1), ), - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_000, + ), ))), &mut out, ) diff --git a/codex-rs/analytics/src/tests/suite/threads_tests.rs b/codex-rs/analytics/src/tests/suite/threads_tests.rs index 49eb280e7c70..b0c4cc0f0847 100644 --- a/codex-rs/analytics/src/tests/suite/threads_tests.rs +++ b/codex-rs/analytics/src/tests/suite/threads_tests.rs @@ -830,16 +830,16 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-subagent".to_string(), - turn_id: "turn-subagent".to_string(), - started_at_ms: 1_000, - item: sample_command_execution_item( + ItemStartedNotification::new( + "thread-subagent".to_string(), + "turn-subagent".to_string(), + sample_command_execution_item( CommandExecutionStatus::InProgress, /*exit_code*/ None, /*duration_ms*/ None, ), - }, + /*started_at_ms*/ 1_000, + ), ))), &mut events, ) @@ -847,16 +847,17 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-subagent".to_string(), - turn_id: "turn-subagent".to_string(), - completed_at_ms: 1_042, - item: sample_command_execution_item( + ItemCompletedNotification::new( + "thread-subagent".to_string(), + "turn-subagent".to_string(), + sample_command_execution_item( CommandExecutionStatus::Completed, Some(0), Some(42), ), - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_042, + ), ))), &mut events, ) diff --git a/codex-rs/analytics/src/tests/suite/tools_tests.rs b/codex-rs/analytics/src/tests/suite/tools_tests.rs index 41e94976960c..9ca6b21d45ca 100644 --- a/codex-rs/analytics/src/tests/suite/tools_tests.rs +++ b/codex-rs/analytics/src/tests/suite/tools_tests.rs @@ -47,7 +47,7 @@ use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::McpToolCallAppContext; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_protocol::protocol::ThreadSource; use codex_protocol::sandbox::SandboxType; @@ -64,9 +64,9 @@ fn sample_command_execution_item_with_actions( plugin_id: Option<&str>, script_path: Option<&str>, sandbox_type: Option, -) -> ThreadItem { +) -> ThreadItemPayload { let mut item = sample_command_execution_item(status, exit_code, duration_ms); - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { command_actions: item_command_actions, plugin_id: item_plugin_id, script_path: item_script_path, @@ -233,7 +233,7 @@ async fn cua_tool_call_lifecycle_requires_a_start_and_deduplicates_replay() { .await; events.clear(); - let item = |status| ThreadItem::McpToolCall { + let item = |status| ThreadItemPayload::McpToolCall { id: "cua-call".into(), server: "codex_apps".into(), tool: "js".into(), @@ -254,18 +254,19 @@ async fn cua_tool_call_lifecycle_requires_a_start_and_deduplicates_replay() { error: None, duration_ms: None, }; - let started = ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-2".into(), - turn_id: "turn-2".into(), - started_at_ms: 1_000, - item: item(McpToolCallStatus::InProgress), - }); - let completed = ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-2".into(), - turn_id: "turn-2".into(), - completed_at_ms: 1_100, - item: item(McpToolCallStatus::Completed), - }); + let started = ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-2".into(), + "turn-2".into(), + item(McpToolCallStatus::InProgress), + /*started_at_ms*/ 1_000, + )); + let completed = ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-2".into(), + "turn-2".into(), + item(McpToolCallStatus::Completed), + Some(1_000), + /*completed_at_ms*/ 1_100, + )); for notification in [ completed.clone(), // Missing start must not poison later deduplication. started.clone(), @@ -330,17 +331,18 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 1_000, - item: { + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + { let mut item = sample_command_execution_item( CommandExecutionStatus::InProgress, /*exit_code*/ None, /*duration_ms*/ None, ); - if let ThreadItem::CommandExecution { model_context, .. } = &mut item { + if let ThreadItemPayload::CommandExecution { model_context, .. } = + &mut item + { *model_context = Some(codex_protocol::items::ModelInvocationContext { model_slug: model.to_string(), @@ -349,7 +351,8 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { } item }, - }, + /*started_at_ms*/ 1_000, + ), ))), &mut events, ) @@ -363,11 +366,10 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 1_045, - item: sample_command_execution_item_with_actions( + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + sample_command_execution_item_with_actions( CommandExecutionStatus::Completed, Some(0), Some(42), @@ -394,7 +396,9 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { Some("scripts/run.py"), Some(SandboxType::WindowsMxc), ), - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_045, + ), ))), &mut events, ) @@ -466,7 +470,7 @@ async fn collaborator_tool_events_keep_response_ids_when_completion_races_sampli &mut events, ) .await; - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: "call-1".into(), tool: CollabAgentTool::SendMessage, status: CollabAgentToolCallStatus::Failed, @@ -480,12 +484,12 @@ async fn collaborator_tool_events_keep_response_ids_when_completion_races_sampli reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-1".into(), - turn_id: "turn-1".into(), - started_at_ms: 1_000, - item: item.clone(), - }, + ItemStartedNotification::new( + "thread-1".into(), + "turn-1".into(), + item.clone(), + /*started_at_ms*/ 1_000, + ), ))), &mut events, ) @@ -494,12 +498,13 @@ async fn collaborator_tool_events_keep_response_ids_when_completion_races_sampli sampling_response("turn-1", "response-1", &["call-1"]), )); let completion = AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-1".into(), - turn_id: "turn-1".into(), - completed_at_ms: 1_010, + ItemCompletedNotification::new( + "thread-1".into(), + "turn-1".into(), item, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_010, + ), ))); let facts = if response_first { [response, completion] @@ -785,7 +790,7 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c ("retry", "calendar", None), ("denied", "drive", Some(ElicitationType::Approval)), ] { - let item = ThreadItem::McpToolCall { + let item = ThreadItemPayload::McpToolCall { id: item_id.to_string(), server: "server".to_string(), tool: "search".to_string(), @@ -809,12 +814,12 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - started_at_ms: 998, - item: item.clone(), - }, + ItemStartedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), + item.clone(), + /*started_at_ms*/ 998, + ), ))), &mut events, ) @@ -851,12 +856,13 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - completed_at_ms: 1_000, + ItemCompletedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), item, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_000, + ), ))), &mut events, ) @@ -966,7 +972,7 @@ async fn image_generation_events_preserve_transparent_background_metadata() { ) .await; - let item = ThreadItem::ImageGeneration(ImageGenerationItem { + let item = ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: "image-1".to_string(), status: status.to_string(), revised_prompt: None, @@ -984,12 +990,12 @@ async fn image_generation_events_preserve_transparent_background_metadata() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - started_at_ms: 998, - item: item.clone(), - }, + ItemStartedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), + item.clone(), + /*started_at_ms*/ 998, + ), ))), &mut out, ) @@ -997,12 +1003,13 @@ async fn image_generation_events_preserve_transparent_background_metadata() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - completed_at_ms: 1_000, + ItemCompletedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), item, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_000, + ), ))), &mut out, ) diff --git a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs index 959dfe72c471..06896368adb2 100644 --- a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs +++ b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs @@ -34,7 +34,7 @@ use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SubAgentActivityKind; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::WebSearchItem; @@ -474,7 +474,7 @@ async fn turn_event_counts_completed_tool_items() { ) .await; - let mcp_tool_call_item = |status, duration_ms| ThreadItem::McpToolCall { + let mcp_tool_call_item = |status, duration_ms| ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server".to_string(), tool: "search".to_string(), @@ -497,13 +497,13 @@ async fn turn_event_counts_completed_tool_items() { }; let completed_tool_items = vec![ sample_command_execution_item(CommandExecutionStatus::Completed, Some(0), Some(1)), - ThreadItem::FileChange { + ThreadItemPayload::FileChange { id: "file-change-1".to_string(), changes: Vec::new(), status: PatchApplyStatus::Completed, }, mcp_tool_call_item(McpToolCallStatus::Completed, Some(2)), - ThreadItem::DynamicToolCall { + ThreadItemPayload::DynamicToolCall { id: "dynamic-1".to_string(), // A name collision with code-mode exec must not change the event boundary. namespace: Some("custom".to_string()), @@ -514,7 +514,7 @@ async fn turn_event_counts_completed_tool_items() { success: Some(true), duration_ms: Some(3), }, - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { id: "collab-1".to_string(), tool: CollabAgentTool::SpawnAgent, status: CollabAgentToolCallStatus::Completed, @@ -525,7 +525,7 @@ async fn turn_event_counts_completed_tool_items() { reasoning_effort: None, agents_states: Default::default(), }, - ThreadItem::SubAgentActivity { + ThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "sub-agent-activity-1".to_string(), @@ -533,7 +533,7 @@ async fn turn_event_counts_completed_tool_items() { agent_thread_id: "thread-child".to_string(), agent_path: "/root/child".to_string(), }, - ThreadItem::SubAgentActivity { + ThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "sub-agent-activity-completed".to_string(), @@ -541,13 +541,13 @@ async fn turn_event_counts_completed_tool_items() { agent_thread_id: "thread-child".to_string(), agent_path: "/root/child".to_string(), }, - ThreadItem::WebSearch(WebSearchItem { + ThreadItemPayload::WebSearch(WebSearchItem { id: "web-1".to_string(), query: "codex".to_string(), action: None, results: None, }), - ThreadItem::ImageGeneration(ImageGenerationItem { + ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: "image-1".to_string(), status: "completed".to_string(), revised_prompt: None, @@ -567,12 +567,12 @@ async fn turn_event_counts_completed_tool_items() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - started_at_ms: 998, - item: item.clone(), - }, + ItemStartedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), + item.clone(), + /*started_at_ms*/ 998, + ), ))), &mut out, ) @@ -583,12 +583,13 @@ async fn turn_event_counts_completed_tool_items() { reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - completed_at_ms: 1_000, + ItemCompletedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), item, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_000, + ), ))), &mut out, ) diff --git a/codex-rs/analytics/src/tests/support.rs b/codex-rs/analytics/src/tests/support.rs index e32fe28aca58..8fdfcfd98a59 100644 --- a/codex-rs/analytics/src/tests/support.rs +++ b/codex-rs/analytics/src/tests/support.rs @@ -31,7 +31,7 @@ use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSource as AppServerThreadSource; use codex_app_server_protocol::ThreadStartResponse; @@ -545,17 +545,17 @@ pub(super) async fn ingest_completed_command_execution_item( reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 1_000, - item: sample_command_execution_item_with_id( + ItemStartedNotification::new( + thread_id.to_string(), + "turn-1".to_string(), + sample_command_execution_item_with_id( item_id, CommandExecutionStatus::InProgress, /*exit_code*/ None, /*duration_ms*/ None, ), - }, + /*started_at_ms*/ 1_000, + ), ))), events, ) @@ -563,17 +563,18 @@ pub(super) async fn ingest_completed_command_execution_item( reducer .ingest( AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 1_042, - item: sample_command_execution_item_with_id( + ItemCompletedNotification::new( + thread_id.to_string(), + "turn-1".to_string(), + sample_command_execution_item_with_id( item_id, CommandExecutionStatus::Completed, Some(0), Some(42), ), - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1_042, + ), ))), events, ) @@ -640,7 +641,7 @@ pub(super) fn sample_command_execution_item( status: CommandExecutionStatus, exit_code: Option, duration_ms: Option, -) -> ThreadItem { +) -> ThreadItemPayload { sample_command_execution_item_with_id("item-1", status, exit_code, duration_ms) } @@ -649,8 +650,8 @@ pub(super) fn sample_command_execution_item_with_id( status: CommandExecutionStatus, exit_code: Option, duration_ms: Option, -) -> ThreadItem { - ThreadItem::CommandExecution { +) -> ThreadItemPayload { + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: id.to_string(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index e038e439f691..4d42819eb4b9 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1134,19 +1134,22 @@ mod tests { } fn item_completed_notification(text: &str) -> ServerNotification { - ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { - thread_id: "thread".to_string(), - turn_id: "turn".to_string(), - completed_at_ms: 0, - item: codex_app_server_protocol::ThreadItem::AgentMessage { - id: "item".to_string(), - text: text.to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }, - }) + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + "thread".to_string(), + "turn".to_string(), + codex_app_server_protocol::ThreadItemPayload::AgentMessage { + id: "item".to_string(), + text: text.to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ) } fn turn_completed_notification() -> ServerNotification { @@ -1882,8 +1885,8 @@ mod tests { } ServerNotification::ItemCompleted(notification) if matches!( - ¬ification.item, - codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } + ¬ification.item.payload, + codex_app_server_protocol::ThreadItemPayload::AgentMessage { text, .. } if text == "hello" ) => { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index cf708bc436cb..7e3ef7ffbbe1 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4643,6 +4643,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -5545,7 +5546,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadNameUpdatedNotification": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 317837bdf18a..669752dbfe02 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -22316,6 +22316,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -23218,7 +23219,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadItemEntry": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index fac312d9ac25..0ce520a0d4b4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -20030,6 +20030,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -20932,7 +20933,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadItemEntry": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index f822591d7db4..a1a5a528669f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -797,6 +797,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1699,7 +1700,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "UserInput": { "oneOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index d766bf8d5554..29c048b1fdca 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -797,6 +797,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1699,7 +1700,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "UserInput": { "oneOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e8c2d6984b39..40d922806191 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1001,6 +1001,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1903,7 +1904,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "Turn": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index acf5e470ebfd..9226add88230 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1615,6 +1615,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2517,7 +2518,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json index 41a14aa33841..9938cc5e543d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadItemsListResponse.json @@ -797,6 +797,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1699,7 +1700,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadItemEntry": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 9a8ddee00007..f84e2cc3586b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1406,6 +1406,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2308,7 +2309,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadReadState": { "description": "The first unread position in a durable local thread. A null position means read. The opaque revision guards edits against activity or marks the caller has not seen.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 27399f7544b6..fdd60d2bab44 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1406,6 +1406,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2308,7 +2309,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 09b124e5a788..7c2a3312b702 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1406,6 +1406,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2308,7 +2309,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadReadState": { "description": "The first unread position in a durable local thread. A null position means read. The opaque revision guards edits against activity or marks the caller has not seen.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 612c30e99b59..7e6b864f1756 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1667,6 +1667,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2569,7 +2570,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index 62277c4ad1a7..fa8c7eaf1d03 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -1406,6 +1406,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2308,7 +2309,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 8c26333295f9..0ea67f6b2099 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1615,6 +1615,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2517,7 +2518,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index f2f9e9dce8b0..ce122a67a0dd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1406,6 +1406,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2308,7 +2309,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index da152655ccb1..b19cc193d620 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -1001,6 +1001,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1903,7 +1904,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "Turn": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 37830b79ec86..87d6af83f92c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1406,6 +1406,7 @@ "type": "string" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -2308,7 +2309,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "ThreadSection": { "description": "An independently persisted, user-visible thread section.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 98f92049159d..ac0e869aa635 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1001,6 +1001,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1903,7 +1904,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "Turn": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 6e161a29f5a4..011a41907f01 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1001,6 +1001,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1903,7 +1904,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "Turn": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 5e5c05fd0876..f7df36579137 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1001,6 +1001,7 @@ "type": "object" }, "ThreadItem": { + "description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.", "oneOf": [ { "properties": { @@ -1903,7 +1904,28 @@ "title": "ContextCompactionThreadItem", "type": "object" } - ] + ], + "properties": { + "completedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item completed, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "startedAtMs": { + "default": null, + "description": "Unix timestamp in milliseconds when the item started, if recorded.", + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" }, "Turn": { "properties": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 68f61805ed0aca2f4bc279f47190ecdf62fbb09a..3a60dfafeb2163ae72e4b2e49e9aeeea547c0a47 100644 GIT binary patch delta 123989 zcmV(tK<^#Th5OiCC#W=Qv~7NpT3A3Jr5zln^; zt%?(Edz~9;!ZMLb&CB7_C~>RJKv&6sU??mUbd<#5A(a}`o28l$V#lfxVp_~L>fNETSzdS|D zlF#}+YC5h*PMv)VVkGoAh8`m@FA$hLAc z$giI~tvKFF?{e0slxUz|M|W|bt9%@-Yh_=%KkI;iv4xPI24j73ZX*?j8ZQ){#_?q) z(M~L)%fTS^cb>#3$!%jojqeYCHOhqssv=5D#RNnpFEA4h&AC(4B?~FNb=wS{^c?rW zVmTmehwaR1UyAl|vK-v0^t&K|AcFAEsb|ScU0oZ}DtHqVaU^DfDIcdOd!BJvr?A(Ra6= zP7=o&$6#OkI++HjMI8Vc7@D^anw;bAoJy{0qxBZo!(+vsO7bT^u75pB#a^+X02l64 zlGG}#WW*L2i{MnRyJMvu1yQgujWM`eQlnAI1Ybfc@%x-tbZi`9QTi&hN&l4nh5h(N z9ETJm5VsxF+Y_zp^Xo5vlenn#yJSZL0FVGfFaQ7)FhC|4h~oiqSh~x8L|_6Dj18R* z1eHXhKr(<35QD}*B^bki0FM9w%OKzsaD)-C2SrgB{hFdULAJtgyYKV6VGc4#1znr$ zQ7%0p6hiCBbJ0~odw3yytREWQMYk0j3(x!1I%*SaKaE>c;s2|DPnZra3uWzWjdodt z@klY3f=DS&io&&%dLotW#WJ$H&Hl5(|9r#pj@S<~XpuO{978zAXJr=eX`umyMsZ4o zKtFKrj2t9r6LpPk7n{7G%Ipt+JL{~iSLno9b-wR+RR_Zjx!3YZaxkp?z?<4uB2fB%M zhQM)IfB?#ALPg-T6y?1iPCE~~B%+5_j&#pnD(298Gy6H8RpDz$BiX_{Em0XsXzy6F|87zJ3lsj zT|LduZx41Z+Q)UWESd7P-qmCBKe;Q4gjLCvn`VlplYvHrxQ9+~IHH{Z{SFz2FTlBv z4%`4rgJ8KRgJoBlCXH=WXgbf+f?Qg7klaUmA)DNF^awaH24;odOV;taI5b?D-oh$U zZ(U@_Jk)c4Iq??sm%zAskah|zuq_D;7%1E1vp-d5vo}_qB(~@rW)ugE#gl7UQ$egw zNeqv0(V3PK?hx>NmT*m7yTlS!Ws2k(34XBUbp^(Vr%lSxWV8zn%h%8+8T6Z&1S)>! zkO9G}3!t%0RsNd7!j$g89H#qDbkE#7x+ioYwQ%I130cOM?YE2z6p zw(n#!ULBq62dPGG6+$!0(;c|Du;T$M^Nj@+)jtmv%qs1pj^85NU7#Mj=yI(N$_!p* znsSxcrLZU9s$q?-WlGSghVsONq#%`5XVQ&-WzEU%DFZ7ooOfubzcd=602d5?l1l>( zZhWwx4$(4tw84xXE#zDR8Ado(1ap_of`e-A|@!?qQVzl!Yl*zzsa%ra4b z?9oJqN|*<^K}WB&wc{(+tjzCED)23zy9 zNzA6m9*Bp^Xo*tvU`JTmI1#~4HmV(eVl{#STaGX7D}$o9$yAGn_ifmL+7T#FYID`> zUH>I8X}0P_bLRI&7CmrPM`?4}#MKY0M^LX5d1B`HsDRrHiU$QFs9vq!xsFpakFNG0 zk&7`7_Du`)aiQ-40&RpMfvk0%MbQsJK-$9HQY09gH1Vmk@}_710%*}?{5`FII2|^! zwJ4xO&;TXC<@b9^SxtT)^JqVdF$4k(Vh%{XYw>~v6S9QL3GG5wp8Oab~1yPa7pf_B>*0x(SiJX4Tr;7%u{U>i`CDQmNel z!d0@@o2}u-qm}`y!00?&b}F;)O@f6FM=rtaCKg~Twdd)Qm0L|wBTNrFQ>#e8)VYl7 zp~0OyX$i77wVDb!8PaGsrPpfFXqw(;pb7ZXvrtlcmpek@S~jKR;@-r!JP>R;bKE_# zs6^R?D`heMaWS+^B;tjC3G3FT2wmtTXVy3;5xsF!NyejNZ{SehUr>uieRB+Dl>s@> z{rPYkOKLZ7v}pRkIfdSA(`dSCB!9zWwUO#@#G9c%i#qjy%H!T2BvqaOD~KQ}Nr^jJ z$CGkp;MYGdlny20m0UMoA{y!d)77e|Ly;At)%Hb^W}C*)AW}kqO^(mZy}1d)tnoTR zj`J<4f6GC-^e=tQSYm(K;(P5C)<&c2JBxIh{+#3i4}t|Fz}r>``buAlxU3nrz*m}(V~eqGSaV7oFgjzxL-*=-l6r|Wa5t+c`F-{z?xjbMl1Ajp4pFY{My z9np+j2MR&tGd7-oLRZF`HUuvWKTQK9lhq?r4-yJ-KuotU+bKvI+|E^%3udFqMayCKRY2rhugeZy3l!9vZT6MLsZqAZVnjc`o)G-&_+UT@WaW=WtIurwI zt_lQ8_?2GF)_A7Y9AjUmPi6ZaN(!thlG-tQH>DDKBJ-Gkuf0sUwmvHWycF&WA=-DX zbLPW%Tv_Nax0t^7D00bk;0P!}HJLE* zhzBUH^8woG+m4CfMkN>tr-+*(wAhbK3R>)lN!;Laa-G9hs6-LThVm?OCeKTBCA?fz zdbmPpIR}wfO4doqp#5+gZ;$zBGgk4gOz&W$*L^> z@gJo~M5>RMtwzu>w74~dr%jp`GIACuaQ&BmEIq|2h_qP%@l$O7`Ii;#a}?c&tuW;7 z*M#ZKn^I<%DLiu~yB+ggyux*rQD)7*K!h;pW~mz+LH`@)cm z-3n3omM_>WVO>-2i1J4DewAZ_$l7ltbsjur-4|Vk4aBpdA0#)+P6*zFb&7(N(} zFP;OR{6#PHCLf4Ol@DwG(#Pd>m|Kf1mi%G|1|^u|fy*&%X}7Uwpr4F{$!jCQ)6Pt5 z?FuRQtrXxJg_+@@mpqT3h;U&GLXe_=BzNBOkFpgqnT4Xl;h-_-;*yO;BlmxYOi8!6 zu?xeB6%(K7^hL<+Dlv5cDC;U7wBToJRymZX%Y&;uo79~a?&`wKmO zZrjwQPUG)Y*KN>}gaNi`-8OkgODoDndyS;hth5`p($@5K+QfRLKgM1N!m(ip5y>d#jnC)Oz2TA?P~w zsu8qgdV*s6x2&fWGd1EZff(t32YTv3TV8ibGq;ZiTb2 zL{Ou({@O)f3>{pM8S$GpkzmjxAsYtzI>7(!`>)Pb!V9r#ao$F!zwgUh1BK;IY{Z|J zw1EkZx^c-j%;oSM7qoY_6}M!6EEqmH(5)7j)Fo70X!Th~I-qAHn#SdS+CaRP93B}* zXW{RqXPEB{sSM_JNKTI;+F*xV5OBHlEV)U#?y%dkwdio2{wahtDoXklrv9t)$ z?Q~4+pe@#JAHGo*xx$Oym|Em&sPQs*)|Ug#x9$-hT}$;catw5}r07@HDVtM~Dn^#l z7|8@1BFJq09zs5oPop${8u6GyhE`*U$PH#`DzHc*FsVeUC2ubSl3|oLgg=%9q}=!d zJZFD95%rY*k|`pGU?mEGG)#{3I^N6t6Aup<=P;H@LniE=Z}SD<7Gv-hjz_U!q#omcMzK_w@G{FKCmv%p zj=&H{(GBM{m$VCl+#-K}IpX;*9_C1;vk^Y(+@j$ZPop8wgH67EDT^Ci%C`SqW5~m<1pJ^W zjLnR6BF{CBolmy8k@Nncu;-9fDpvIXQbbY>`P?*8E=&)&C5>qi-iwz*WBSBSz6A+L zBO7h$@m10QJk?@yXr>C{04fW zxR*`pX?2arG-TgNk3HW;WBq7;dU~dN#4?|v+(`x;=_ZbXs`2qJE;!)Uqf}TjwBmEn zOISfUk8UF3YZV`Qy)(gtuSZ`}JhZueh1J~`#}MVvg&nfDXT)qcs~!Li)sN{KJ{Z#JP7$DVnSZ+Pwq<3vu??_D%)Vt-ww zp3G9mW<=sRD5>bEemV3{lh0t=N}Cj|bysB^I1o8AbIW#C0JUSH*enG-A#WWlRci|~ zMABz}D*T>s8$_ib90w63(z~hAF8|wm@;BHu%kySEp!I;H^`Ut!CBMO)5=T*rK|XkV z|59#EJO1x;@eEqG#e^=?}Vs|*%P z`mIL^BfzvYRk_?l{p0(HWKo*bKm4>!1kLP!yhH_DEuK3E7pd7=KQkTewK_a?;%7l) z98h#@fcS9o)mGl=M2|H*CZuuzoYg_JA6SR1H7?#%6>6n_Rs)YVovuFs@8d&H9{CJU zyk$ocTLl`+6Mo$Su|PaNXbBAJq8QNfg@XFgsN)hUo^v!g<-9u4a%T_|BnXJ89A{L2 znYDW+`S|g54CSW;lu$(apWp=d?b`Mvn90Ze$i0)1YSM8K9k7XK4pOa7@OAg|eP_p2 z?c=m)12iGUuaKcKkwu5IVSfkl@MUqZ1CV;Mgr5Agvq3SeRxBw}OdR6sj8!{`tlBa4 z&0mb`r#Qkbjd>}*bF+VA-L_%JJ7q?HF>(B{VU=}=*}V|+hfNWDdd`KpGayAn0iJH8 z&?$aI7!lx>ut-6a3P&rzAiMK{w6nU4%zNyy6Jw|j&Lw(VXif)?tztMvq8lgAH@v2x z2Om!iOU-UG8!@_XyerWVi11$#z+~+UjnP2;c5b?~6ISXhP_&QrXE`c5smmyTZXBco zk=%HRgYw)Om?XK;zD^}qhYgyvQheXb6p)%M$RsyJd2u?Bt*3i}L?R*Fd^2=6Kw+tPQ<5w~V z=`!WbKa=rduxP5c3X-Q#^QQuTP<9oBEYyVFbscvKp>6!^P7on(@ z{Tz1Yu1kYRXyZH8FH_P#UUjl^N%)(2>*0dv&Kp3xl!C8({+f0+Yu#{LQEY@tCiQKn ziiydBNxsv;0c91MCKU1sLpJM->|h!8&cEJ&ZWSkcxs1uvx-dh3d}AhbQ@AH?TkPp$ zoT1U;NFclZX>P=R71B&hcMx^JynH0I@Au?fX?yqt&wy{*=qkkF5Zfb&kmAQgLE3lR zCDzs-T4_V8u6R9uYWW1y8s~Ixbe+Vqz%yZFC)_g}fvUMpN8r zSl?IJ#JU14mp5~Nx*MEx%PxYHea>jezuYe1KKXJFInfY=yzhP9Ti8Z5NDrDg+Zg>d z8M5Ee7!6_CiRa>+LxvE)v+>U5=ImcTCDQyMYtsbxiSp zP`HOb{XT{Y!pYAhzNLr5QcRmYelKFyVR>ho1t@RN+B!CW$B>U&)e12ST@ZTBr}G9A zr#_Ueo3a4tvAjlCLO$WUk0X9r=@gr~5=9g63-VE$sY%vR zB^%<1J;n`{r@Yz}Frj_E9%N?gX~mwt749!(6X}YX9G&;*#Y`%*q8LWvbG?DgDDj=- zWLd<4WCg~{2-a(h^JoBdZ#Mi&yei_3PL?8=WCoGWlu_sEgatn5le{0w^C61ytrF|q z#a0`CM>7$v#~&Tsd4Fvg5e!iOR2DEDqIV=_@dqErUxTi{6Wm|RswN|fp#Z)pW*{#u z4=u2w9)?1ZeZr|=PXt-w4!!8Z7xC-b?GLe6p4EI}p`dB}6vc3BGLURtUfBfR)>9R{ zAxMY?cV#azdl^{HcHs zCxGO?xbR~6bR%3Bpy&z!`&o&!9iB09xQWqias>X{6ifE_>Af0m<-}p*For0Zj2?TQ zN=-0M^55p*B?t{3u566eqIm%YxFCw16Xn_kWKyW=3Z9czLsj;&7t!FvrO z3cL*5(4*DOC5!wtvnNjPLzi9Rs}tvcGCvThA#db=YP{M?AIag3!Z-69TZ_iu+nhN~ zSzxt`ehq)k8?s7JLaevF#fXQ2bXEEXu zQl%~>2X2&W0ig4LhLtD8tdAs7{1A_zC6(l5T0NKv+v{}Pg*!L`<8En^ue*u60Y1vZ zHZXFDsj#J4pT{&YM6hV&m6p76*A$~KTUJ8w*fcg-?u8HG4Hn3~6#^G&-0Nd-`U!3A zDwrq7OKCa5X$nevPHMd7MHl&hxlQxxE~R6hRFo^@=4dJA)cwJ0sUAK`tFd&?{bfO(@g<_(fus$Yu@c#@iN#sf3 zw9wThT`ftmvw8Fz_W_;U=Ja*9cTe5;HS-kZtPdVRDLKf0Fg0rD zHEGhhu_A>_FiZ)5*--e)*YFh2Y?u#Eg97Fmu?YVsYPPh{$HLhP&6+_6!!W1^+;(2o ztjOUo8>L`5hqZxhZ%*zZPDi)a9gHtp-Jk=LPQkb)8<7k7ntltk3tP0NxCjG^SPdru->CJ>zNnM&3EI^trV2j^5y7;zf#-d9DV#!%gn&A7|1F|eSs1XHn3wb%#d+hl zIEH>iV9tAghEJN?##aQO4=yBg6Y0gk(i5<6Zz<7LkLVJUTBq`-e@czcXdFs^r3bO} zMtokKYmhb+%};>r{>&R4I-D=pKasYm1zHKU7leanjN>7Yadfz47~!A3>#5(4!V4Qie*PAk*-N2-rer&%|6%Y;!+0n za;JGw)ql1U@UYKVMB21ZI^iWq5h3Uz5)!BY%_0^=aEV86q`B!NYyx}`4^0M+e*PZH zyFx2yK!u??op}L6Z^g853IFH!8TY>>@)Xch{w=sphi3z{+aWLn1!=NtaR*Hp0AaN1 zKT5ZM(N=q(_~$3RWT4=n3F*~j9X<~gCg#Lxo3e6N zz(FI0c7_A}mXbauW^e$yGz^0W{FdF`U+_tV?Dg?Q1pkHa;!K!vf(!YY={s$LJ zvbRAsvvXlb65$&)AFdU%Dx;%5Y+g(@OhRyf6*cvsA~GGb#%>L5dJz-K?{a)u{S^Al z8ljBR!!DF?>1dd>yq$UFkdlSyRh2{rqk<+<$qNT^WCr2N#Yx(VFVZ#E8B&CJiD%h= zeX*s$65z@Os9HuWo&SVa8h}G@xd1{6FWrH)@#@+IS|drKmXNpiea<4_?y2vZpLsZ+ zfKV=g%O)ZqWZ46R&QAI>FuHpTjLt9kB^QRmy+W^EP)c$p!0gNk+*Mzi@-yK_J*g_x z`kG&?Z@lGOhqTwk7m<(v5;Iwn*x4R`qxaU4Jw*`Sz5a13ySWl3gJEC2`}MK_jv}Zn zJb>%?C;IZsyBRO7+l4!zt*pQSYV#W)F0(Ml5>8$a;Dupp7Jr2};N`^Li+*FQ0HY&P zsIWg1x23Y}l`%UqgSK91-&4>%t8D`o$GM z;R0-l;8%T!hG=Bl_6!Ism3WhX7cH8MGsMn0lmO84Qy)Jx#~$v+l=FD3NLEf5nL5yQ zPWkzq#J3&DaTommsK`3!Qn+$`o(e7<*%~nOAEZ*SUtX9SkmQ6F;j7cRdeK#$5fg+s z@+6j(VHWUCr1bv-Hd)KT{|^=wWswK(@r_%*tyu21LHc+T^OKz|{ZR*h)!sT=BFIdW zp8*&3-0%m3OvFOr3b^K9A`lR?y66qaJT{z~nu~)mD!#RFHUpg&F@c(au{|Me+DBJ@ z_K#jl;~@cIqPQyTOO%0CMTn$wt1^*LZltU$(WeovC`5W}%JsC(3_spuw)41Wuo+Q- z+HdhWx#+>McCp#{pd)sFEkWJ8Elk25UvxFMQH5>|^Bp~lF!$28cjHr5wd3P!C%X2{ zfb}hBfOTiTCJ%ZdR6dnHTncsojS*}0zQydLg?QxcT%*r$cbc`c6*130 zt;psTP4bYM1$u0$Uzf-fIkyN2C~UmhGPzBK7J+Muy`|}S<=SR{ak9F}Ng+>Wp&jG7 z-d{K8p$1!vrz)cFq8Nm_w1f2 z%I_xW$*w4pwtrtTGPW`&PcgfUucqDrUC*y&3omWyOYo1QEKCs>xRj+ne)amTNsJXL zF;a$8LOb#Ca09o0JB?YsXez?#Ui}lSb;MoNVCZqS4qTr|AbP+#R#W_q`h>w!3icFF z2Db+pNqPe>+}?~afxVY2J=$!wSG`o-00Or5qGz|RU&~DpKj% z%$;KXk;mYzzFkIkon8P=C!oXCCgZm7a>1{|fdwIytG(!d_6)3$<^PI6x;DO4rvkk_ zKZRc_Kc;9^xG)4#LN1NPTr->XEE~jaTGNAXq%)FkTomF>vE>AK zbV7abxCROh{f(PDbBA|hbH`r4&iJ=^6@HMoCN0iPkF$;zFAM{?GV}xnX{|Sn3GuDI zoyz=fGcEpqL;5?nXg9?_kYD&gF2&sn>6Rp#Y*Q>5!(k9HF_F(P{S0^mVM&ob zVnR=j10!WPhS@JXB34@17fn(pVXu!Ag45|vcMbKdBokTj9%ak3vP9nKfJ)|>ghjrV zN-Cg#Xol#WS5f*ZIy}r}N484($n_qmz^rxiyRPGoPOvJ2N)bNh#4IXR0N8uUa2fue zWuPMZE4{Vcfr{j$G$HV76I^85G*WpdZL$!s)fI7@qy<8WeU%d2(c0x*WH#FBrcw{a z-6H|*Ri;V_qQYhHIjt8H7%$i!S60OOa-9`{C|rnN zUfu57UMX0rbBUj*m?0#_jdpbQS)7=msjqF2jO*4O+wU#+tFbGVSBE%+$>w(T#aIB9m^hT!2JqO z+dV41K!=8Xxv7N2``do+TO64?0^aOvYR-c zgf1v=`*;t>O4?=?8B>D#pJl5qL=6Yq-;jZdz-ye2#3+2x1?+&c6JLkpPV9HJB0{S_ zf&bBSE}XIjiB6HE8$P@BvI`w`8cuxE1AW$Ff)C0BKha5HYB>C;s!7@dC+}Tr%?J7OiA@Vo?!6+wqxR(N0Ai4Y&CLQ#9DAM()_qX!=C_qLQuCR?;dGlw|?x-b&7| zv&pL(sd6Fm)kQ<>Wa%lN-4?KDxV&HI=)6EG_YZT^q*BWcyL+~_j`4`HJ3C`0YAeU` z6~!{aMLAZ5Gj7qWV95i`=1C`iXO)S{ysji)@i%w`)aPP5Nz0mJKKIj={MWb3D=`|^W)4PkbFiOtQh-&7l@ z|K&M_ZyrmO_*Q(iCYXQdims=&AEu``GCi&a3n#HUnS z|1#7q4}fGGgyQ6XoJ>FUwmlDuOCYxxx$Y570cL}xYitV7<}Hm*s8d z5!^B#=Dm2D(M6;Dt1oBO<_eZlv))T~Nm1{^X1gj!5dP7=q+gdzMST;f-Jo#eX^N;^ zb2uWBJBt#R4$hUUmNJgxAY@VItm43D=F#7oA6-dq@!*7A=dM*6yzMw-P~ z_>Bw(|J74}#&*|3&7RhOx@{#i#djY(SGqrTIC*aI&0$)& z>c{hKZ(?f?ANN+u|5;Xxo^Ra+)lgT~;EO)ufbB=sS!;`KD(0f^hsqN?U+4%BHx`C6 znG~Q*m@f!z+GcJyUGQBuu;d3srAp{bDzyFbV<@6~fA8pMx|;>Mb<)FuXuMU$)1}ZW z6c{XjyvFyBeH}Bb8g89A5Eo zQ=E539)@IPXhmlHIVwYohm&DFQioM)*|sYjl+fYqpvylx;jHN5x@u1b?JS1BH36qL zQhrNw^bS35?mj?PqMbLM$_j~D<}2uOG zwKe_b)_d<|$^Z@kUrk2X=36^B60fFz*oGfTFuRc~V)d#`+qrs}V$ZteKRk!LT| z{IUK1%@h^lK2Uz?{^A^Yl&bUM&3&kY?j}jTfwgJRHiGGXeHyQ7#9> z-UK!DIzMEry_yqZlgS^8cLXzkJKi@jYUjJ#dF>3PSm&N;U&qnmr$VcO*P<*;N~$UB z3Xq7K0ChmCW7gU&yH?MrN=|rG8`D3aK0j5CV&(r61P1w$w1`wK6tlN4C0B+tOcnp4 z04-$@RJEnpd2p(UL_BOJbVdb*5XF59^xUf}@g3r|&s^f-Cr0(hz~DuHr?&C3#PJbD zo2c2s!6=G%U1OR&<}meaNX%aXf>+}c-nbbp7?b2PE|QU;RKFWU3{RXPG*QyPAwJbG z6E*rh@Z>l>=TS;1+sg?C>GK-JPoXp*p1K-v;)
WhJco49hH!SMtMh(O! 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

f5H%~ z;~<5?jsrLWY_qPTxslk_yzUj1Q(8n%!`Os`9+on6Jou?*qFC^AWF;?^=lr_)lA4%T z_VS5`m)Hp(E?1VLXDNqx1aavc#ARF0QX1ltPo(53yJFJ3bg@*LcTm|sp7zjn63_;? zUYWKm)tqQG0e^tK;y*)Bp0tzKfALx~!)8Q@d4*=kfYq5UP=1nmg1Q!+MTG!SE`tA!6NQP;vT7$kPo5cWX?Na*!GH4 z@L8)_35*g{A@@o|v7~^xSIjk|Yqh57e~7)}cZr9Qfte(7EkYx?t-We&dRp@a(xh-3 zt&p!gZNtbVT2`8NP%IOj8!lft;tPI>hueH5k2DD7zMWKZc1k4({YNDBXep5$|Bds^(|-CAgq!@ko2j;E8+3|s Og?BdHj2TD)TB{7dVG-j1 delta 123822 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�&^`#E&RWFmuz(Z+YsFH_q; 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 z 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%0Drn{zd*&^qk61uWH`I@Ucqr3}&c%^76j(Hdpc zC3l8eI2BRsK^#eW0d^|&4u5xYh&KJ-I(;ufjw?smM{T=73X)cXGV?~sH%pyPQ~ftf zgc~=r`5Gp(58v7|OFwc26G{_TOUPn2p!%XQSRVGE#uj8h58I}8sDC1iCQuw*R^*&0 z%IW$kXC3qKK{-fZuq;TwiPoStyj5v6YErj9u4XyZ9Y<`awrn?_donbdU$l%6!{!@lE?zYt7|^#AaCi@qz{sAW!fcbYXJ79r9&YbZ zD^Xne3}XuB4e;KBSphND7sDBGQU!|kofzHe5d%sB_ zTkTHd#oE^|bQNUD>HH?L1(so4km`&oRFC;GC_=4g#GTzyY7^vZ~|E zWuZPik>~o&4DY)|zES}nY<&g3)jlbGy9)04&lV4htoI^5zIn+yQH>ZZNF-8;7X4G2 z_W`HhUlEB|vwyyL;wEI6q`ZGEG?%lYgOLw1^OS(db1hnXf`Wmfts&rWwkx&gFm}#* zl()7-I!iCDkW(pO^ z()C!7i@2p{k6~O(Z{gZJfV|a)yr(Ee{_1JgJ*BAqsVFJGyK5LXEuuDY8K!ranK)}R zNqIOM541_oeKDp^$i5+U0Vbv@4FHNBb?!7o} zFgB%dyY186#Ykw{ai&j3Q88;!?U_ymRDX?M%ezDy{bMM_bwC-1H!5{=j`hfEDxay^ zgI)8UfS^a>m-e_M-?|XQX^W2`0?cTfu}oefLrV}7U9Y{thm;)rZx|sU|C;`SKLI9Q zW@-yJ7i9L`_cLq>&l5RJMhrXpECr=r3W-665Vh2Z+5a1+3_Z1JgXB_GO0t z=yb>AcEZ@e2}92898>6kRmg_w%<<(md4}}!nzC^S1hUalLr9%3sa?t&b!nOakj{{X z8LxK!HKCK*^kw6k| zqpK|w2mjB39w6;eV!haP;(`Nm1;VRm&MXwW;7)3PTqbiVS#{bE8RU-@g@5P$$X!6+ zj^F3=N>!=*@zjy_`=vwFXb3>z;dFl6pZN{z`pxbb9o&@W2Q;Y2*$J7}P%c;c-NU@` z0)K<291Pm-O5MS%zn|Il;Od?68Lu&) z6VjT%C1QmSzJ8nb1v@~(?|*yL+teI<(5WpjrWqG3A#twavxnQlVbpv*1O#6Tfq07c z?tc*^3t5s5wQN-$?Uc0ux?kPD!qjBzOO_UT3UcqR(E|Bz09ZxMQ=@>EwIQdTlB zi#xMiCNqzdsdxtVxt4~%Daz^&urgqE-%-5|KdE>J_n@wZYCG72Kr?T79Wb(ljj=er zaL2q%NBij{wkG-wK~TKYLN=->f>N{qirO1aecppRs?VY1hGZ9X2YKx}-^-moWNb?0 zfx3r!iq+0Ci4p`(L4TVg+SKMia%^)LZ^mo#i6?+;Bg&V3Tk$m3NVPm7#}Pa$ZK6Pr z!#KnR^VW;ynw@94Ubu*eFoBQSWCD4}`xNBeWPM4y2u4w}McPp3r&ec?tn|DQ3#^{X zNWq*jDRM$}lJA)HP0x4_HOU*_3!z2ojheDecahvc7q=lZdw&BpX1?HSMHojm|GX}7 z)(R~1cU#a}o#{w2Yx$x}lvy48*VnNf`mS*GRrFBfQ>GLA0NSmH{!_$nw?xMz zfJ-n4s>PES6Zu$P2Ko1t^4-$YWpnpuW-Lle|$?1D>=cTyyaj%&vF`6U0m z@%AU<4}Vsv{vVdGu+&IO5nnLmLou@XTa6T+G#!uxiB;5ZIulmYI=8Ts+BQshGDt6WG# z=0X6Wb0Kw>`YH=QG&6Nzz9Fub(Ue0L4=Q{ex__0jfK-Q2(bhtKlG&VYpLbF-+jIQ` z#zfSEuw(+_mc%ZG4-s%^otT_E#4s71AU(vbS-58gElTJ@NPNFdh?)>2#8MqLt>qUo zp=&~9k!(Vea$yG@YUHmNUSSawLNuXCxw_PV@QH=cgkWE+V(qsRZv?SP_@BPco)B^x zKYzkr*0BX<2W9_>r)r$bkwuW&#q*$5#E2{qB{B;n5@rg_ zxpHn{gLD>}Ab`BO69|SSMpcvjN9AiN5wE8VuTlks>kub6=!0z&=JuYwXG2Iw3A9`2 zRv?Pfb~GG;1W?r^mRo>4qMd6N?0o%}(|?E1k{F*g-1bb}7HHJ>OP zeAG@f(CF_mojIcq^-yDDl67cGqhGaiP<7lt1bc7jhks}V<~c7febJJvMK#lasech8 z;Tc=(?2Bpeq;X^l*ZN%}>Ta%egxS=eR=QE;)3TwAT+Jx{PB6+8dR#wxH#0n=2z|FA zEuyz2#Fwq~*_AV!YynUyEi7Udt+14Ui#Nc{Swu{6%UM&~JhG*}G-9$y(i>-)xbbai z#uH2v#SLCTRSyvCI&HfKQ!Cu}!+*Yh4g^L)RlRtj4CYW%5sjhr*FX~G0VS?!xoxGi ztdw5LawuvB_cY95QnRNqN;^BeSgS?=^=_}OtG|~x=H^7_e`~hS%g8&H}hJ7DHfPY+19LAxN zE-OXnO8Z(x43RXtL<47JbPHize57850P86;`~-pQPE2T%vjW_10Fnd|7jUf(;2I#w ziQt9)~%6UO7c1ubX_z-d&lWgu9BFz4p1OTIs$lH`@4 znx%iFAGkO-gwLphKgzHMvwt86Bp5&-Ha-f45*Y}R$PXhhU@#CE1OrBZfdCOe5Wo;% zAb1HR2p|v$0#F121OmZD5D+FAqBBoAg)$1AlB^o!Qv!wG zSE7(>YWc-KwOykXMVnKx@A!+P#UbS4!tfrrQW*FLL1l|P4ia2O6#|V{50#g90er++ zu&|Q%A$IXV{<^pwc|{haX1DdKFcsnBC0<}<@=%u#uLlvjn(iWtF~fp+n- z5MIooZHA(um3Y~&tbYq5-h!C)5b=H=Ot%U4c!d38cmV>~qvI@MqYCf%1hTorj;9#- zf_D(fs96{_R*2wbzi=*NV-ru1|1JUHlr{*OPYK@df*X7{=#$$F^#$@m8C~Sy@7?wW zYbeMpXByGG3u$m;A@9m|O2)f5BgnNSzfHMwcXqcY#@Sd%N`EtuS$6@2W!)J$HB*Qs z@puM9iI4SkH@OQR!`xxko=|C(-iZ_7uuQW+l#FuT?rOpXC_Lp#QW$5u=mr`Wr;^i@ z1h(6$fSGPy`5%}dKSeH)BJF}N_!fjkMSoPnb6`q%8)lbF0g$2{4uUUq((>H|F?a84 zGRE3FlyzYgzJDHj`dF9@c5QuOz^%Q#iUd> zQ@382W!W+UQLGqwm5EqJ&`MocrQ!Ot*Yc;`E>LUDbbrAH8G-=2pIYrHUFf%(dD0z7 zF*NBW37b4mwa7Hn(WMiY5YkUnR@x3GZA-S1R4^?w#`3)|=OOv!F*h;7NK=wX)36lD zh!iQ=mr6GXpo_S`R+HsPATKkcR5hCW>98(AY!z=Ejk*GOq)I4Z1!2Wp_!Pe~m{)k+ zTtdv6Mt|^cRfjhBKG;QlSx~LB&|Hjo_0m{UZLDVQy)a6gdJs|kVJ=97Y6SQ|u>|Id z9l$jabseExZn;w?B{wZsFvUE}9dRIRM;djf%H5JAHkhg6l#7=zssPOctJuacT}lu5 zhAvNd0-b3hJ~fBrGB&;-kx_!0ja-WbgA9SmN`D90QG`Wuaepi1C1GqPt4P9RcifTi zWk}NKGC;?jLcELh`ZZLIyK9gO07F2$zYNi$bjC#%yo-=qu%g*_akl~)>!^OY0kyb; z2EAf&%{DnvTml53B4g+#?v1czab+=hMBK?6gAjKTL=bmA0oBw?&T=Dzu?@^W?l(mq4J4KlISC$&e_}zGV zzZ7ruEd*-J=Uc%FUP(`{%~y%ZTt2{x$&v9owzq#xBis@bnG5lfjo$9WER#txsVq5f z=N0}?ikft!8;vIMah*y1GRoj>nP5D^*DbxoDNy!X;$6FiEJxFBZBnJO4!gy1iwjn? zuew!Cd=?d3$kQ!Y2&n+q!N5A=xdp_kZ^c+@TXQ?cV2FpP?at$fwurkNd7nPZ!(7D_ z2-$xUUN*yvO1Nc2l-6)M6u8w&0A?Z6BQKY9;S6Gfwaf;5M5Fm1njVd1hAl_tC28{Z z*luVZXH0~Gk$G(i@oO1mp_dF>+jX(a|I#E{Ma##)Xs}Eja@vwVz!w({z&ex%`-=~Q z(XfS7b>peX<`#ky0FBf%TT%n{to(fzI}3jVGleiW#!5PKAfRkhHE2x_QwiaEYz+a- z%%crvXk$B%0CAU?bbQfAY%yZjN+83xj1JpPiM&BQ`*2QrQX?VGN#GJQH`)hZ4XkyB z<>6}s1DT>Q2y0mzYyf1IrDD3c+KB+h zNilVo^A5Gw1zUzIb!g$QY6&50n!-<5nej6(iJ_TT1S?hs6={?LVG%0HB((gs8+LjoxZOixe~i-hV*X-8rd1^HYlcY4yc zg;Q2lSWI0n($a`hC_$bSQ%Jjnqfj@~Phrn8O{MTM^|8%1+Vjr3MvH^P?qh$nxa)3z z-ZQ1m0C7D}$(0s5zM|E*rD6yTF=FZ7&}9D31!Y``ebmge7jm#0NVLY>;xfa1Fdh1b zDW6U-&Y6gqt$;oMmfENDVPddw86T#UO-hq zht&%PCy<4New=V}o@|azIDmg6E-*r{*v`{=g!wO)Y|g~=;)7t2k5jT@LFRPDo7Ft7 z3bUIkjHhG};NPg8!kA8nW2cix#?qZ5%*Ubzqk5^oBvf1iRJ8C!!hISFu?#WR>8CVS`d~X?1@E?_I_t8ct4j z4~BL^(xrf1H%1(c*u3tC?J08^9{sLy4_oF7Cj_J+Oh2XK?W+jt|1)c)w~WN~6AWC& zE>9d#v945KIO5C1)ZnyJ7AZZ+6j^5|YzlKfDm^5F6Dkn&DNRb%-qf7R0#p?cQdOg> zLRM8O(lBXNX;LeT3Sxhk6PL^TLBElj@WC?&teGw91^@EYd>A?&la1*IkCxi+al$)Z zP$dlbwL*AuZSdPd;a7`YhraJtE%Z;beu(eiRBHG;NTi4?LJ~ZaW8j!kvS_FyD3T9k zx=M*Kea&!~0miztVub2ydqr!YK@QbP_5G}|V{G__neH!3KT&_O#FPWJ(F~V)cTtx_ z8s_G*wM#}UlLvy>PcSi_+wfk*|&ed+5}>gqG3}H?pM-Px`NGvGO;%P+tvl^WPxv1?F@t-VovKANNfm- z+Y77PnY18MA9?1o_eaGJZ$M;j&Qs zDpA!Zy@r2W=_Z=L<@RD{3YOC!FhP+IL{3vsW-MP$SH3Q(yTZFtvcFx+cz`h0(4kMp z#^M@HchVAb;!9P!0C$VdW|>2gdCiy6G5X5F1J!?}GWmbF4eqnH#NHWcwH;~ooSMq* z**?%@(^|BDqo)Y&a5mBf|9?=@W6*hWHACY$KZ$=hNPyoJn0LLF{y!WUvBWV+R6!bK z)1>5_f$G`+GUK?nBN*fp={<;+5Oi9!2Nw$`$mgL|j3Ia~g1)BGAoFt087P8~B!c)4 zH93v5qfT?2WJ|CUZkSFLI{?_mA$5>j-4P@{do{!1Ky14y9Pe|7AOWR|gDxF7Ui&T` z_^f|IJ&{Ir&k5+JQfb%hygLdFJVNE|Fef@h+mUai;WZ@>00cX!GFkzRROwGXZ>n06 zf=?-2u>&Y8QKX;>UV4j1MRZRE{DUF`E`^htz8}6j0KSdkpD2gKyxI3qp8wgchF7^E`2&yhuz<{Q>eSd!l6ouEy=2YPYr%98{fRD_$zs5r^*cvnW zGiF2;^S_wEA?R_$4Evo)JIt^hBtFB;=!KbngYf|Ndd9S%9$v{?)kw^S5}#iNE4+%F z>i9@LNL#%Sd_I00s3P<5=8IwcxmL&h&1C}0q>HedX+F}SeUq6$w4$bC%Y;$r(d>UJ zKT*QMq?&PzBiH>~5_M2eq|#@`HLS~BI?++-B$*^SVdKz=l2ao^Kuv^`koME~C+ry* zaSV2%0p?1efBl*H+ha^7aHJKxI4DvU-#><0$}})J>A7hHQlw56w+D$-0ce0nrKF2+ zx?~~Ysa!hD7T zBIOQcjnIEULZ=81$v<1o955V@^k2N3p$2ppegZZvaJX09cZ!qOQ(m{lR z6iK?FYLS-CS$fQeH9?VFC_3&6zRKHrtU>`~uh%)RwI)(;v*IO)XJ~2@A2Ak*L06u?)yrFo{ISyJF z2esmVw=*1kB|Op!iXl{Pj`VW6{RDd!g2l_=;w-UgSG#P1tmAhrfP@qz|5U#FD_~ju z{*VEyq-5}26(NHt6sz13gInBIcU@R*MB7tx)G-)Sq26{uFQofUyamNGK7(y!QZN<- z!Ak5zESwr$K?WA3|Ehn2*V(D%?3B&Z?6!MoY84DSoHi7s(II8uFNBUT!MtH+-`0l0 z*^lK-e~}=s_$YmjNJJa9)0G^cL8)o~3^MB?vP%>s1Q8I^6^AF6JN>}%o?IgP_1IAE zfnp44zjxqunCMwq)kIWrIM5k8;?KQq;4X>cw;I@#y(;cufu(=Rz)~ERjJC=1hymdP zlNJA6V7~`&_dfE>_*EbcUDT;SN^JfW7DRzgK`E5s6PPF$zmiRJcp=}| z!fLVzxcF`=zW1AG?!dij0*CAOcID3FvTEnwy;(J=uT@Bpp7#DpAZ4ThDKqx|RB8Rw z#1D|dsT-oWBHw=*`c1EG=JYm1JGD>g&2Q$3Ybc~c3l5o)mF$^9tDKmjHRI6_){2+u zZ8wOxgVbrsR_{RL$r7rAK&c+*pvJ@du}CWJmmXOH-dkxTTOEt4>AOcuB-&=9$aff@ z7U?FP8L0`~!0;h>jv|VMk;uQJ=L)@RUm`jtNKyq!Uqyec4bKKwDgAEW`L=H%E zvt;-(Dn(fsJcz8+{Aj~n@FUb$b6>hc;JNK|193`;EbmbgO+dMd@W6W8JPoygTO<}wpdEy$u`!1g zC~KS8NZi{Z3Hnw$g^vvIHa6~{k$B|TWw&Y7n)#)hJKl188f~W4y8eajT(zd zc0J0-5RFBQk=?K*`stu4hcu5AOFN3KhdO`Wv3+W^N;^&>+cfqXi)7dGP#Ii|J)bFe z`K%U_4jNh&4I*jM4~;mnZ)FJ278d0@uwvsF#O!NGaW99RCpUI46_uYt(U_(0p2eww}S!4y}*$1$vfoBTsJ5~1ABaJOA){<`~?dyLw zx{1%fuAce2gM6Al@%4{A0k{8HGLkcA6IXaTyYH5U(Ch0t!n(yBj{bucIOh|qESxgm zU87n|)lK{-wQgM#B^53KcjcU}>px4%=$bcY&($r{6#q#m*I<;^7&mz#EHRv^alICB z6BuKzMS^S4P$n|OicbdatxJFlFW!HxcV9|SvVSX=Z9R@!Y6gp5QnL@KSl}PFhP@d; zYnCp4tMxmwAnjs9(E_!GS=~TT@Q@sXL@=Wc#?rp6NhSeNT9Fc}JzL##;x$<>%aY7a zB|;xwN$M&TBK3EnboAvyv}aDIMobxe0#fAks{vtfQt2^4V78A;?B<4E4s$Os=u-V+~i1#=izeEXFG#2_|Ej|cS)H_(P4k*H2aCjRs7s1x_Kfz zL!9U}0vebUOn%Hu^F5@YD_UmzRH)D5nqv^@gV}Fp+C-N6#=xQ|S%X%p28AiBUXbxb z#1yQ#>iUXj)VE-hErV9+aJ09xW=IIQyAgdF^vIRB3h2+a3M+q)$v}b=$b@{UB0pRE z)a>vYR8W^Eunm8wL5SH_-eMK7CmZe}wK)-Oz^ck}a1_wPNt^h~MAb>lMnP2-B~^7J zrTfH96Ai@&YSq-P@bg5c>zXONPT2UMPFI)@Z%xQ9^z_Y}KSwKU^!~%qYVE(B9Gn1~ zOqD+a5@;<5*VG2IdLBz%@vJyktFBwnoV6~$hP)-+v3q|$V#lg2mu`H?(T#}UE0R?~ z@Yae4iR!SmcKw*BY;3=wK^n)lG_5hxV8fcD1q2bw;aO>(HV3PR0dTM?pAqc^){sf< z{VFlL#ni9t7_#UlM}6!;>ppb#u_Kl)kh~7g$NTP(}$koH}(anKD2Eg7@IFEV#bNe!-_t-z>O*- zl{$EzP-2dnb(JyKEpGvxNMMK$>7 z)<5mFmNsrejclfw;L7hLyJ{K}qYt_Z^g8+Q?s{}%sfZ3Zb%8EgR^hn4GEvGpxTF;> zLIQz;MK1*?APytsfD^1j=ULFd!5w)__k8Jo#6vV@k=_%M^5$2}k|7Aym&P{Cf_A{s zJhp#+asa&~;*>6x_7>XIa?q1TXUY--K7ju=g)@n6IwlKIw9 zkZ;8Wr&0^)cH_WGl$pu53JAzKq1ZQq6&>Hl+(UAJ)5$gCTWf0RzKyGh0`RSb1$~1?MRZDM?bO6LR3}~mK;CcOOGtk~ zswOZn)uhJ2D>!#N1lXyq=H8Bi&uoi(Yu{!ziq%dH$mwd?F zYx|Y3F~y=Vs9tm|Y;7v+2SWDhUs$F(!%&p4w-60u0iK03e_t;F9}WZTddZ*4tF}`F zf{v^j9IRHA#(Fsy!Rpv7u>ey-^>!9tS%jB*yBX)CUaGKTIhzU8&j2FIAeYus0cn5s zCOBWa#pz}(Dq5Vq7~MJETxyx09_@7g2Z++B1V6bF^aC>h5D-AXpyCpmKpc@~Nes&U z1Ykgdf&vBtf(-$Jfdznp0R#X706GBx6bJwS5CFUc0uTTz1cM^wS$MYtl}U;9oF?I3 zW~T9rPX-BYV1Xl`2LIH8FokTeagl$LM;&CaskNX6mqwfT+o|KCUQ14;F>r2WU{BMe zpc}?5AhpqfXlCE+W!&nNY$(*{G+NW{?8AuKElvCRmGwgbxDKYFVgm9s_smZz9xzmz zZh)$boB9_Z4QVte#*hpkO$UoH1}7m6dz+A^x|P_Y3p;%yMzz;`lS<&U^jd!eOV`{A z0LQyEe!O;>4zeZl`n*@zBHONH3w!Z89XRsccCAzk00$gZ^l_u6^af18$O@lqtg#Yg z6WZOacn=Ac1u8f>2mO#>Ot;J2$3bp$+0X~BGocHeHiFJ(0Y=Q9<$ReCJTjE55qVZ? zk*1{)+n=$Z-dvEWbxuhxMC*U%1_6k$TJ@`b@th5`irIQbAj`7sV5EbGa{%|Hu&=++ z*9gYQpv3c_GoP#xKmsI@3{s}* zm*^or1R2f;Uw{!{0|8Or%fY7754*mdA5d%WmfDyMdU58V=epY18lHbeCLbK`Te2nJXYkUyw5Wc5pjvi(*tNI>QeUDUq9V(X!r~0By^;LcxasIIh zEr0GoTJB*kRljv|kI4$Ezi|&Jh+bUWV{t#)Kn`=8uleTwIjdyNwNTafQF4Zq6t-&+Jixz71t;Ch+tl37>Gh?qLbIQ)AXD6UxUhkj#5Q%yN^jtO|G zQ9z8GIFNcAJ2#scV0YLx{A*DjIF#b)OnM|%NCg82ghfW*MP zk-)vG%8lpax0mhP>yT{8o*x8%sN^LBGwdZT4Z{i5*PZ_f=yqAb7qi;@bIpT^8>9eT zekisl$!iv|bMD^NOo<8W4gw_W$%iCr=?6WwXcmA1H%XBr!|M#}L`#t4`MWF2=!URr z(susf8y0h)R+%=Jiz6@L*7H(RRsP3%@qcH$`yzJ{k2-%*&V3nNM1dL3L<}^ku_A4y z_MLD*0Olphu37fdXafAO6e76i%~XQMK0J(>-f-lMnAqBNREK`uFuyi-=bDXe&I|wW zLcF{zOiNSAv=bjbC?s6n==%u@FK;wAOjxC_-dUVX!RGn_EW3;NZ2P_nytNcBoAS*m zR@A;>?T>#xA(V2{qj&j3umzI7Kgnhb1l@$Y$dPKH8oMXtkoMXB?onwkF`xPd$B+VmOA%0)ChsHZL6@;sWBLP9hsTN%D zfroSf4(YnN{J=W`A5zkOT!z;)rT|6;d4-bF9^QW=1E@vV4t`-rV?5jI4t_Ky{9}e+ zz#C9)V&AJUG$Ej3>|$OUNw}o6N54vdfC|O|F7sC4R|8+14(65+Th7r@Ps+a==BiLAMWE2#X@ru!tJ#^0s z2Q+425>PWqv6Y~Vxo84)UmPftNnp2U5@Y;6zz-;W@Q*4Fl;oP$L5-6o|sCb(CukQ9gbUch)X+Hdbm8 zlxq{lsmM>>@#Q<%+2u0O&9F5xbOQYc)93*WUYtY#UkLeN>iygqmIN!%4;FQDFvowr zaxg~vKu9(jOv_`4qBfRXp&>~yMJ<^uRS5Kc1!*7xW5XR`4uJmU!!?C9z^Hf7$q^8M z!48aUH!EPWSbC!ioIM_EG_?cx{}#32oFJ!|0DM$ozbp;l+ny!iqJw6VZDg)oe10fc zHHvu8YKDYMl^erVO;n;5kC)FGorr(hZIVZVMC5=_D`gW6=7ZT%(D7O4QQ>bT82c!V zxAXN3!*Ohfi#g*?G@}Kkd2<}5UFC~S-~lhT70&5KXP&AGqskZv;~Ic!=iy8Y_)@wP zQ=V#CF(Z>zMg~Omlb|p~LCx9EriE&%At%}J=uwlDl7GK7A_W(XF;q8}-_n0~E7Wy? z5_qI!0-?K8_J6VGEuj8CkE`ssO9{WkI0y4&JWkUV;R6)CiNBTS7bpNKlk}QhD06B^ zp6V9)W95Ef_T|bdsujhrurHN`vG-|zYD8XV1*U62y(+wnU$H2Z|z!*w}(X8b0f#v{wyoXam zMTfFu`O-S>#WYs^pThmrtY7eum?;AFbzg8#Cs4> ztlO_w89x{QM)Y(LqON~kS11#dyH>5{ZlvE9N)3!sZKN#&SgQx;%V4Zp+2uj=yg>1L zu&QsZYm&KLtC1$sU4_rkt_0-C1;r^9xeH-Wx|&$F3*2kwTb{1oN7q&}slsh$l{S9J zq9Q^+8C+=RMbFjbZQ}W61U-7s><_!3sQTfAdnFmip@|5Fm;!(KPAr|c*6a2rtWF?| zZ@^PBr}h0m#RN5}h1cR2GsmJxjn-~@?V(g-o{A0Vh-tEXR)G<%Trs~opf5?NSy`C9Wg zaWq8!1Cy$-0bqX=?zPBX{B0%~E`9Hy@qJ0MRYVCa^>C$&T`=)CSFCswItg{r)36{T zITVhPmXj`*-c=yO1^n$ovkFLVH9cTJbaRVxc%=IB;JNEF;V|31gH zzZ+mJP^*6`o$JwbP!1E)6`6lfh?+43|9d8#0gyIpe|at263FmluDInYlpk5}+cL?* zkHyrsK-N+RVfCHBsghMIY_OP+=PFC{a53OXx^d`Lv=&>x0cOudc;`FMHMkqxE+-Gi z7IH?7ZTThY5qJSx${&xUE2dAe{i?$wXN0vd*S3Fa*JJL|>0%2PEQUUO;mUsN%|JjP zUpbf41TLAV6HKA2dRj)F#F)e<`S4VOT z`2Hdw_KOhb7h#4;mH>dc1~46~sR~ca(Rxp{WjL@4fD#^04jlbd>e)c{aL1{@^kY`h zSP_2`RRtjZNEwK#;6v5bUvTMA74{CVpDO!C0x6<<@Milytj9e<1oNx@L|#8SbbAF2 z4XQrHUm}eCd470+#XJtNDpnu6Ly@pUaqbsqLN$VS5UFAhsm4d=yoOX245<$H{1DU` zfJEv3#i%q?&y4sPqC*VP;worCv?0BxXq$hGVRL@fc#WFJdC@f5sAarxVH_^5qPUrc zVk#J5gMcHz_Y4c!ujc-d*=Ez&gA8pDty3@fC!b?^L<9Bw#i7u!lx+wMCe^l z5lS2O5~c&;(<1U9SyVCAPchZcBV=Bm6fsp3XslsM3fjX|Y=@~XDfKuit{H^@A8f+Zr$(P?;189whhLSXIAN8nSqjjKH<$%D3^Q z!r@ERZ`QgYS-n)0wpOh?U&2+2&W#d1c&Xxdsrm=c`E!>_-Y(Ux+)Mdams(y_q4QJe zLv}7TIdohG=-ON^Rp>Lh_UvslK>B~p)?$9h3nxqDQB6w(rCtx2&2fnD*$29^4|oU~ z%{Hlgt;^@o385U5B&*svR>X-9B=82WV0S<^q=}S}00D+9!_w;fJrE@$6t6L0Zx-?k zS;&#ZtG?pyf$3Aoh{vWC-23)v`oWw1039mVwmKWyPtrdTqA+@KMc7aHTvt$;20)erSdhs%Fy)WnZmBWW@qk4c&Do>{Cck;aNI#_U3*YhL>b=QC6s zcSZ{9O$Gwi68JQS=tS_jDJ}jn)17j>$hSL_i2^mwWL#p+^K30LP!C=2kO4A72C_m1 zRD=wuK?V+j3>X3#$N(8|e+-;-Kz$7Cxjtif41hZZ*74Hlyp~q&-u-{fF;EbX^CTlh zFAQl63{jVFK^^A`ArHM6zy`IN8<+kXmx39W#?q_z;!+%Jtq=bTypE6m`$eL1-e=!# z`RW%~>-wP(f5$N7h@PSm(ibplz3}D>NSU31Tz<0L=#^v!QNuYAG zY@dT>MU-u<$Wnu|sKL|R4ogcfG7z$GYqyi74FM~yV&nY^q()VtAxERt_E?4?(P0%P ztZ1|je<X|51e>AJ=t84TY}o4k{nM9beYAW`c0!{FQzrpg?lev+ z(j+`GqRXvFp`Cw$f5n_goAjjnMJ(yPFQ$iq4=3mOu)n$khXQrz(1N4Uo90nDqX1dG-E4#+t!gA*dPTdAVL^B2*>(L*R! zIGCk|?eCz0`@KpOZ=lv6z6k_=U)OUfp!C>k@M>+@Bz`Q82FH$-*@@ca zXB&STpF_glnpS7egOUMvVhbZ|jQZPogQCoDXK2$E4m9(g_ai<_o7HUESuV@;F z#@4}BCvL4y{l-FbNm-p8S)G2&`H9tueE~h$)jEH|X!z%4Rq%vY5Q1f@ukFoVoq_)q zyB2bSb|bD+I!a%NxJk%_{t2$K%Q+W)5pA-Y0cjTy@O^93iB8(+_(gF}on3m9$&P>7 zb=g!%@spJh0DyC<;6wha<*?4grQG*}NIgmQAw|LLYznPS$eILz)s#~lyquIDT9D~A zxjF?qWYl=&W&vB#DxLqM$a#7qXTm^gOXMs9QsxZE8M*h!pVt|{oIDP;W22NuTMG9U^pD zB97D)#w!%cl0Ump$jG>v((1kOD+y)#kNx#YdWfFL@(xXp{G%Rzn@gkQ#M7uB&yb zplw+$EVn!&0+UyGHlSeQJsA}6yPs#MD_`<$@In%SOTn?vfNv>XEk24%woq~VDMDl@K(OzZW(`bzy`L^{ZQR%GiPB`N>mSWPrb$3a#h01EGc9Mx~8LW zi_1ujdlIy{1J~)6^)wjMVUb4)Iy~y`!}jYBXgRl6(0)f1_%4tZhTeGXFTRV@)@4kz zFmFFI=!&Gf-r>)Ld7P4E!IS)6CdUtNpo)2!FQY0vdRl$oAvkc$-FJUv3UuPWPqQCN z?K`Kv`jma(Dcz_Pj0oHNN{dVl*?M0#uAkhf%z3=NpFxn+qUVAdc4(&gi#9V`5>{ZzG8u zM-X#xba%y9b+R4N=zda6J&NwA2vDS}8bIhiZj2#eX2#=*=DFK2 z{d4DEi6ftTX43GSVSWtN;o$4}=iG~5ZU?rP*S+B8{%qSoR%bEww4EZ;|jOHY?1bRN&` zI_Qx5iL*g18CDX7a3~}qMqje4c;b+uVu_Z(k9#6EnB+bKcmJHH12fvQL(}~!MHUI8 zY^wu9kSzA}HA;W1*uOB0m|~x<^`Wy-?0$v-_d_37mX)}~o)tZF#nw+Kv1e5~blcL3 zFQro1vC18U&jf%W1pp7+u*=S|`P%m%F~j=pG5;q&=Xb*wW(blvNqu=g?#B{t1MJ0q zOQtmztfjtT6Ph5Rp_3dL4X#<(_vQ-p|0Ekr z^`bo|bH$$wgP~Eiy8xE>cz2^F`A?HPKF?X2rRAB+Uup~p{FKJ;3yR81?g4*TrKk1#-kwTFZ9oFCBzpX6MEJ1b z4OC8aDGg7g98xgi#Qu>@6e^tLgdR4o^yJ~nXfPw+Bw9U`KX)aRx)+aE3Qt?JZzEB( zNaQz%k6-kKY#V$N0w1|XR>7{-ePozsTX?XZdH#rOaKDM%1|pjqk-tf?V7#EZ!pcny z+GT&6A@E_1s(8MTj4JV*-Jhu+CdBj8fvzUJ{LZhodwcWIy#lRG*JO3=>Uw7Bsl3Oz zhIQK(oT;m44_D@L1~Rbo`1b`&j0f8BI=3wY5Rzvk@>p!=6ih%MaMg1eofA9Rbd&Fe znt<=Lj7mSpCy>ZQ5lz&yRQdxDz zmbnyn?RSWMicMmK@_b8TKSnyrEvads!T#BC{@F9shF1)hTmdH0?zFT%+g&pJ0vof) zCvC%Mf6#lS{qblS%Y>;vu5Dwu7;Gqzu~hqL!Vt+f^~cql%nIm_cVNSD(O*Sr;qy#! z6J{Fb4+9xu6ZG-N3XRmrI)DAS7z`WeVi;&_Ed=rhtzU!U#@ipo11c*WOm4qDlG^A; zt|MZ!j)FUJ$?C@&(dwrG*$SB^=|_}~W&z4TaLvKzU%)yaa6Zcj8(D%9-fjhE7D0(Q z6~#~Ncx7KuZW4?~82vFiJ&gM7AWpU?q~9F0VFj@S%X~lWg}xujNq+%on^?g2>r?k* zyROe{@Oi4#9VGkc`?Qwa`J;ELbDU;&?z1#q{=Gr9uO4D9<`@~KM+_{{Kz%+H-kYsN z61F6AUW*Mi^cLM$n{!-YcR;}9NP#MGaGu5~Uxs4LsiWo89gecNkW&X2U%_&A$=~7! zz@FWmrD!f+2LmqH>3`N4$c|O{&8xsodSl!f#6&!Ej_PT-91-K@Gz!|>m7jf1=HwTF zgqWWHLj2SSK?1j*(TQP@D2(JL-rzKIiWSt0p2fX;BJ^+yq(w?L>(65mGDj`TBd7L$ zie%f=Yoo1DkKtsdQ$w@{kAAhq@ho5VAnt~L3$z3eF zm>nI27sS!EANNj?$YB@Rk3FqzR%Gv8^&!pI(7xvM7K(^a9)%n8fmgx$9oJ6eM`t!M z_R%%mRhfE@4Fo=bwsd3dK=0A-?$O8ERwS#Prm|2Sb-Km5x0zXnu7w=*(dg*1Mey$`8N?uej}(9yoU`U*KtM zr8?|enjyOUIgM`&fj?K@iOa^shtcpzP=;~PisCy(-hUC_vP2R=kD^IOv}zcxK=cA{8}3Tq5>mmmy{-?_c~?P8iLu0mPXR zUxk42c%!Z|SP1hBLB>K3={HnC^j7q52IhC#j}Z2Vt;{pZd|e)4bICsoqF-pxpgt>7 zh7q^s!GB@&zWks8blem1L3CP{F}-gXbtj>^Z=yharHD;A160$mJ_R*7(uVa&dU9up zG3Q7Sxy?{2SIh&UrA@}muC!8~36;A@!vOun%#IE;&5g;+CnOo=nGmi^vMVzUo*UUq z+3?XsWNX9X-;a1ylJ3=u`w0{&6TEacs#6>=M1PsU8F<$yShHMUh)0IPYVNeJGh55% zLZ|8yy8a__aw^|FPCoV4A1p)OZFQ@u&t07Vyv$K+V%v!quny&_(u7~VY6-1Il|WPf zlE*jzAnqDa$7iB66CTB@p@hW(V(GzWUBh$0s>k;=>-=V9dvaT;`X!0FUG(p+=_{@x zGk-R3Y`|3M_)i3=Da~yQwLu^fdLB>dfNvVO%!c)2IMWwEy_k37_Wx$Ndq4b;4q## zzo#0}84)y$f6=t(V=8uuVf+`!XIQ94KaPTdB!nXw2;)5xV1?(+yeK?x27>YO#~d?; z=zZ^V9NQ5vj(V>07>=n2a6Jt1FTNd`1NwgP@UbY~p@;vy(ZG^1-bHJNOa8-N_kY=o zN8z92djhk++NUfp+r_Wi#pS0T!cWOAzV**MzJz~woM79O!3d_om)A&B1Mt)ANqr+8v3iN*sO^cl(E zd*kxR5Zc(6k-4v=yI&_hf`DwZ=YNP6IwuYVmS|L8PP}mGF>-O@c&a79u0MnEiQ??J zw2IMRog~`$8?T&LKJ5g5C<7;c{=Hz(=+pwwRAO90IxY(a3j7*c@Hi3U!Sp*FaaeHB zu{<(h$M#KRb zd_sl|+r)XF9wy{h-3bn*Qr+>cB_6+GMN=S%E25+fdt@z0Yy9{D)EPq7E2jrqZs>OF ziG7t@lbW1fa~b9CjS8Q1d_ztr^ML$#-v0|a@r&IvwzbbyCJzAk{hg@jz#NIF7^ojda=i%VxdlCq=Nu*J>npe z>#=HZG01J+0XU8VPQ#3jQ*lXDZJ6Q)_|&5_0H_4hJWzLC)INm3p_Y| zxAb#a*)}bhn+PaJXON?l_$FgBM`onm=n&*aXUIx-&U#>A_irRlard`g=s+5Q*^CZ7 z65I033M?@?!h=rsV08M{n3=uk2=vhV+R^JKXzY|Ja6K(La!^d{pLF8{_f}=4>auScq_5Cjy?G>^qMenZbqeTDH<6woaNB}^o zfiuiil@H{g9mw%*Actfir(GattUykfK+Z-WC*tFpWdm~R%zrLhEM(7kQvb)H!-^;? zxR)y_{ysr2FLUI6e*I;+0YD+n8_>d!tEgCuN8N7WYE&P1&_&189WVkgvc(lUw{;cC zGXfXt4KbCOenF2jR}J**SakilKgkuXdCNMRh4)qB)$2KU98I%rEh-XVVy6Dmu!|$o znul?(+j}Bmz?b6`-v+BwI0klL6~`e~0ujeC z8w;)F4)VcqP{DELs=9#Va8QuY#%VhT(t(h$fh;GcUv3;I^+jRA1kZlFarWBGm(tW0VuDVku7B~|y{NUZcdiJYo1sxS5gv=m zY|zLJ?VN|t4KWv{7J#cj@5;pAGY!K6tcXLr`j+%vnN9JSbAuL-qSc#n2-j}d!c)P_ zZ`Nh0jb=qdu!!tq_%Cm6&=D@scHj}%4R=6vWOZ)9f`{P1RTjm%zt}#2nvuAU+3qBn z6;>ooVt-(9(gM_xcS zK7W90Y7~;Hpc{E*QIbP$b?yBYoCO{|HqAUXNFJL?c0!Qv9h)6@Y$}PI3)uhAVnu<61nymZboUnzX8;@a9k#?~ z{A_axM>{^{mzvP^X~H!+K9H(YyA=1G2Z-2U^N+f{glm6Hz-V<4AauKY(|?WqWT6fl z+J{QGlL{UgF>amx>ALL|lFV%G_|ZV}^L_}7qu0Fcz6>@is04?FBq&2imeS3er&!Z$Ux0Sx>VIXt=`01k6KlW#1xr6#e@^&+pnO`d2YMHS ze?`x2L6ovLR{44>euUV&b7U^q#(bw#ESVEAm z0WCx&e9ag=%HzF;1iN&v2}llPn8>I-3GYKmCSkDltC~&H1j0tAf_Y#yH1=r`y);Ir z3_d2b6diN2Kvy0>f=bKnPKbrz#Q~#J>6?M5S+!-fk9sc1hcx@A?9B$HEq}eSv4E{l z6-YY2*|MDmd9n*S^{a&t`EWigNz_tPx)7@duQR2gzzGdKOFaj`lM#?m=$r{)Xpz8@4Lk}o?>v%k~6Y`0gUXlg{!=9?~^9g4m;6x~0C#0y0y6^d50<%vKZ^eUKq zz7yTuiFPIc4a-Cem$IznS;o_Q5p|X^=2!0hAwR+h;O^y#Rb8St>wi3T4*55$EN#!t ze*wpgnQ!K*3+E(8n0hG}Qa(agq;B?#CHUn8&Ft>bZFn>_?D0Rl(C)1Am_lQv=?6JH>c{Pw-M=IJ8xK$m|(kJ7m?QR-)y^KuE< zcylOS?KlQTL&?MLXMd$?r*Ao53AJTxg$<^ie2A6l=DZP5&7(j~WlUym<60{_~ID_=lp0_RpWt9}9#Gs@_&k2d2o*%hF2nl;g=C z5xSk9W!t1aemcM0@-y_cTo!)L#TbJX<+#~5;w8>%6`532e}5R@x#`zy0B=B$zsg#K z_Q>B43NqX?7E|N{+%pUR2J3Bmh64;k93ATXUl8W@28LM^nty(sknBD~N!jD=+4KCr zUQY9TK#UCe*iS2|>~5gQy+E+nojz-l_cuHf=1cyLpmF*n1b`S3aKiI2;km?s=ty{` zoB$5sd72WWU?9NRzg>UxKXx+|yV*ZWN(NRqp8ADgkwF0LPNs;SMl`n0>hGD^>g)-o zcLGvOjHJ}!_tM@aOir2KgVSo9m3pSlTk1PFrHJDTT&RRr%2u3`VZL|yc~j1#1al4O zXqbA!;PU{pVf6%Q9L^Ug^AVhu0z?60*7qYg<-i$v#xF-;H3Wa>U_KXI3h+at9mUP^ z6glbA_d6DJ@8AZnXZKs}xdO*Hi(7BW@a%JU7dY!crLqVd^*cnK1da)f6x{Vq^^JS= z4LF+&uKdzB8bJ5G$+S5x{>Fd+@!DSm&?qUf{Wps8die=DpbWF3nPzD47p%iH-v9&% zrTK_60!70`>TQ3*hbT%zl}QMzI}~ob5$JQY zRDI0(O4Bk)ep6Snc=uf z#40;xtTQ=`nPFhm=lp9vW~@;#9$v{yIagb~PvEba<)nX^rM&wbnU`-ww@NLuY_ZIe z|DXhwSv2p50g?N`1saJzw0cNR+Y%AMD)H&N!Tx2mH)camk9wEhEAviy=-L^4k6+Pl zZpq0OHBWd-!CT>D0lcdh6eS0EWc6ENN{{7H)4C(oV0Ax3Z2E`CeZyjMZ<0eNwzP_@ zkrT^ej>Lc9#5hb5bZ%mtUoI}2*tmEp^w3E%DU04Yy+0c!Vd1wNH9AO1gS_?UV8&|X zAQKA^MG@Ib5)&JJg`X?nT)KP+OiX44_Ma5AYy~joFJ3U_*?(jm5TR$JQe#A&2M`ni zIv>Raio*@^OHn6G~ltLSW1Vl=9)o7`NJN5B@^9I?%x@*ropO4`wg!WQscgm+~P&r7{Ljl}QZV6j*odPEcwez-N*e?uW;*00n#)c&ves zM`GkA0$%lz7zV5fl*0tO%6vcEJRr!P~1w!FLuOsd_IpkZlE{O-(w8Ewlsg@S8ux?3H{A{$1Jm8KWA(@xZpB({NmcG z5FjvM({#peh2dtJPSb2&c5mdC2+8odb{HEL=D5OWhWkiur9QS9hnqf+6r&EnN|IeWQz9J3ERoXz_AEC4Ze^dLvlwhi(nLn7(`NC`>Vp&I z@q~(EBE>o(Jixg4p75`xdhJd^${T-7v8j?ki8LPtCumP*e}CIwTUMWQ@uI{ZieU}W z;|(zYbxYJNd8SBcdk2!glO`}Wx#d2>#o$v{#F)8*yHV!F(dRVaUw;S`k7b~gpSfbe zR=%M={~I6VMlMND1Sx6>{CB?a_ z=6&>kucGbh(;cC#Km`hH$bNtH;U%y@GWr%XCnR@;NQP9>Ie0vAP4185Nh8|1^EA_} z`ti`&2+GO|_CZ4@=9$jtmv7d<^>F$Ctqb?Lm`Zq6^YtIxy4Yj*9f;KwxsT`=#^Vv} z;QDa2#_*)ZaLUf2m=I%_C5&N!%4G>&47)CdlYc01vOvY~N~s?N&Xj+P*)QOCH=t6o zgO{h;?}_X-<8wADju_&F^nIx1Zt4|dFTH95Q#9|RzdTkydQCLn;Rf43zpfT*-r5tR zJSukV1*;WxQEEI(UOKF@sWF)Q8^$GzI`pAFNTKluAc_8X%HXhPUjc5>xk_@x+}$Jm zj76s_`~xalqP#4gJkUPuJ}9I+Tif?lt8C8?&mH5*_TdFyHDVj`4W8fs@H$#T7`a`A zhjJ72AcEyZ#Gjlz@qfW&R34}txBwzW5SKB80Wp78%k>Ssk-6;dvTAoM;ZqNXsHmeq zNe4q95J*5!AVI-oA`ixbX`TKvT=>L;sr%f1KWOo5iQ3gF-uY_T@beIwbHRz5HmV{yVv|m>aVwr2dd&l`;Mc$BI&e01$-eu+s z=qhZxN>Ncngd2!gX`cfHJ>G;v;tM+;n8%3WuZ6t~z^|%hdZ^0wP@b6sSz>Jug~-{s zMQf0|GGP!NV#a(urOW{tC`l+x%CXa4Y_J|PfQ~(0*ki|TA`+Z+w#EQ%=XPN_KxTj8 z1wU13pK7-S%GKyR{C44)T{!K73cOIui{1hAU@hzG--W$dpqIZ3Ob-HRj|@vcs)g@@ z%r(1rK?^r7>;v(n|DP0tR9!B+I7%KadeX|E^adbzA^r~m6qDv7yA)T$q3Avugk(%BEuqx{F^}<-#rWUe)Grq2HwX`Ykl5!iz76 zpres54Bvw52~vlGy{f6VATE>ON`Z{t0z4))n<4c)R6I)qs`+Av<+p8sY`_5zD59~U zvukaa&}cX=u%6DX@;5AHEV7KtQg10ZBknvrHi(QIO6S)EIV8--<;z52SVEdkzfZZ5;>mS98l%2AA3uoVVoQfJ5(yLrX8$MYZ!uB(8w(Prt2SM zNwhOZ&`CQxBmF6CcpRJo67^qP^fv=3iJI;A!AE~~qKzAz!EUd7dg+0Cj5w~E%73mA zJYVFsQDbWywgA>>VAy{G{6$le@$Q^F&t(FUnahBmi>7vZ+PVRHo$DW2$?gQJ)lxRX5IX`66Y*BmN@x%v4 z58}q78k}CfZxWEtB@jX-z3>G|O>-N?#RWl17x3(?u2#`leL#O|ELz99&Z>Asd%7qX z%rEQxRo=MPz7jmlV{I=MWSV+!4t}OLVVfVpi!UTQw4wf%;43yc(TzGVI!6iCbAOfE z8-(1FtP>O6Ian1?SJ6{UBC8cQdTGzKwWp|C6#>vpIXTjxd*0Ye4iX7R0)fo{1WAZY z-UKJ?CWqi1o0xxPI9QF4J>(G*xqP8&{CIkw7wx^3` zOd_C5hqa}D=hkx82p0>O;nGbCs@e$Q=0`QJR4$v!o#Z-63-dj>hxa)j-7!u3X3PXtys07v3x+~$iTupSj65v(t$;>cwc?fqAVDbt5 zFkZtiGTsD@r3BsWtCTohq!Dj&|Hf>#qwWo zjs6HB=8v%CkKn~0K?Z+>_kM)L{RpT12oC!Zl=Xij+^2qoB>e~j{Rl3dA3+1mkMNWq zAt67)-S`nq;ztm|k8r?`5J?*EBXp^aq_qI1ubEOW*GHiDM0#=_j9rAn8$EhCaVsb- z0NT%mP+n3G^rZVFk@eTgpBprvv`6>PXFr9S87&o2R!23#;ky1mOx1O1kibCrO<^veXG00Wh} zLUI})ev{MobeuK`y&w;pwi!S8%3c(OATwh_?_^$}Ix^^sHV^$|1W0?qvTh~Qa3md#w;$XLF6L_#7g-8ft9<_I+& znwhhY+G-k{%)<=#z?c)vYlG-JmdcF*M_tGEQJ7t%u(Ob0k4V+)AIX9iks4tVj*NfM zU%$YN_EEH1yH*i}22gw?J4s3MJ$iI*Nch%zw1xekJ zYl)yb5wURWH5$}Ti6y-+X7iE`?fntNMk@KdKcIVt;fChHLDYXCA$Fw~;`E$@KFKEJZFOpk@Fc zmEZiGXD?9whHEnbYT*BAGe3W_n>a1(|0?hYnw%`s0xZ(FL>tP*W%ljSB96faP+wxz@W&N?+`hz|F>Oot{vBjj2L|UqA_ZFEUq#6#+V;r#UH{$ z`YCXXCDxr^@o_1NADW{Cv*j(u-Or{F#);`~iX(Z5Mve?L^7qSb3V(m}L-oNp9Fg~g zDF|RW&9%9Fvk}?<;vPucC|Yqb>-!Qdb#-0yAJ%X%7b#FsP-UA^cUO7hAn6!`o80k^D z0{aK}!Jje=Y&R4@nu~vt8h%K#s|}i(s&a%Kj7&|nIcJM&HQ=w(7;=p%Um~ zepuW0U@wkD0e)0`HSicPdyI^lm+S5-^Tj{di~$D#9&UhHc1M4CGh$@dnQYQ)81c3p zziuV21HazYWYav70zi|Olp zR0>KP&YXsA4E{pNhtE~GVL~6ZS5GD^higl4YR7y{7BGLA6(CFviHp<2b3~EdPJFUt zO{+8^i>}(L1fqZ7zm1bRl2Zf#B>pP!6t;@nW?usraO%7RbzwGEkhLr|kJ(n&S}Q&g zbYC2ym0S%u_)kZXP|uMESU8t^tD z!CyH}BI51WdpFKqU;~$hzCl8PH>II4&s6E0p+J*OkmxKlEKfED@sEDG|J4&hNbzXvQop%lVK6{0LF2FouO8Us#i!9S@PHp0g|5SM?E{^DhsrA;wP;zE^W0YV@Yds$?U zv>L9X;XFVQfNSPp$Eh){8IpwrUVk%FtUd_c>}sidE{0|JU=0Aj8X)&-0Fl|tAq5#x zKjRwk59zR+{cT_NM-7-~+zd)uO1Cc!lJpb{r-e#4BO6K3xduSjpJ-i7{H@om3|2UC zB7A?YIFT0G+uf>(RHiIjGKiKy#;iIQS~i3Up?amirg?`TvQ zy|nQoPRc<8z%*Phh}jCnZ}_*`mx%95Rbq2Lp>9|&IiqlSh=sI-6lmDwFrP$MyiX$W z@!z1z7usY>5aC=@O?d zqyb=3(Hk@X_I&Q{cq}V?ZRFj$lBpe}96Z$4;oQg2u`?x=n5QA41Q&PGAAf4$?<_=Q z^)n_DqNncf%8WUpUNrL*0UB@!+MR#39omR$%6%smng+l&M}vqU(y)6u5C70_k5X+0 z8$K(9y=Vw`>OU2|1bksr6gRiqcs_IyJ|7a!u)D)Nhm?=}tpbjRa&pNwYXy7<$P^DP zM#OzgaqE0jhvIiUe{dJ7v-r7c+Svm*M9nFZpXKLJwSy3v>XO4u1d9U_59oh6lvilB zb8$s6%h4C{Sa#)z~puDb;Lmldj5IL0C{n+}Ii&3!wbEyC3is(99 z4!uq>0_UHxTNuXL1^tU%9yxz~KA6MTr&aX3A*ue^3o^U(1EEX*2;pvSNq-AnC_E(5 z@NlB;jRqX>wrH{S`3nd$wli682>K~U(Hn9WMF;bSCIz!@g}?|V;PfLv?tFz}*%_`8 zdo>GCWE>-kU$F%K$dH@fJ!&owQ8z9%=3%Sz<7c>JJBv9ex5HNGG<1IglX0!9p*rg5 z{J-@#>*S}2e}=^R_J`b(Zp0&#;{MC>^BbXeCsr#j>Z!=MKd~k`zNHq zOfhoqrCRSuiRtVNCh336E4Vv44GfNxGDc6{r|%F@VD_>H<;KZR-NTTu@5HYrMARnE zfT9?hy<@Z!Lp&xForJnrwP=yAVa^PPb`G<9EOnb6VGDMFC@s{ zQP$ue(nO*nSRPs0w>9~=&o1;CIq}EKA->vcFNEm6-y6;^{aKz-hOv5~QQexsZj(~Y zhV(zdK=2v5`*VL|1~+$Xsxg=u?^NW+8|{jADoGBzjqVh%+%y%NCo`Q2Zc0tQ!y={X zBL~c>n$Fil`W**T1msjWGh3m~;Z%D71{o(Z>9HHM(b}_-4{*Rxn`a71Jok}M4h@o! zLMe4Ym|T732Pf)cFjK1oFdpRD+fPkmg-*{4GEa%4#_4}>X)X%Y+R};4Gy>T{NTERC zW85SvJ^K!ATpx*w-AG0?U1(aa#l@W8&xPPPKJE@1UOz-_>`}!;2kM z{XyEr?;gtP*`P1=thBNQ5q_yNVo_)i%h2ZiJlh8tWqA)k13jd?@{p47kg~c%Dvku3 zKZU1pbMp&0q{=3r8&X-oAZ+59054}U0Js!QC3}BdNGQ@{{UZp>X`$`nk1B8i0{VH1 zk%?m5Nm+0s!1X%+t#979H&gDu^{T%0HnZ%EN;-mgN$$TyCla%k4a~RzXOp1Cujet`GVV zsAYdZ?LpfNy{)4jzD$Sp_^%t&_Y5 zJEQ7M>UPT996FP_#Wed+rzL_S%gBP`Lmgni@mp=3sp!X~9xAgcFO{CGq}Ak|A;vV; z2o`2@6@s_hM=n0|+o7a@foFxE2EUR0=^KBlA&V5M1jQs{S~ZlU$>G#c?fgbJ09n>I zB^=d392PlkWwt()_A_A)?YQr|+Fs!LHIMW}=C@up3X?!$i{=}*dVH*fe?eW~kij#| z+ICH%X!NU63%>G~d*xG|PkEkj+K$cf1U7SkNP{B1n!u)?*zUI|9q9_<7j2dM9NB;1 z!d8S{E68FFQY4wDXmFy-OREdP>zLf?tm6R%Jq_Y02;tm>kW&OaRXkY=lqd}0I%&YE z=GBApR7VkOR&ND8dXUOfn> zj05g0x-+%*G=e}+uwr}c;7QOHwCaCCsTryn>N?O%JGVM-`{AXr-`bwIpCk1)jbLM4+8!EwMAXc{{3OT`J4L5SD)`m})|Y z&q%wLYJ%n^*DiXHmy@w}YRWx3j8Ib(+%FI&OieerpwKEcu~TY7-TYsU)HM39#6G8^ zHYQG$AIQd(*UzZP_W`f@+wEbtJGjLJP(8b-~-D7XJF=9hU8hehr319K`YWX>($VR3rPMr72dikMRKd}iLjiY%GaC$>UE!AJ_hnb~=>IO8UCh61_OGV5 z-lElb))gHXjEQtbXT-b{7?}azWexg-;^hH`eU^i5rvA|aDt*Gin z0@I6};LfR(3?6^d<@+mTFT#qkrfp5+2p6$Pua@{=EFJm%b7&N&ZV}~}7JWvJt0G2q zc%lU3;Vq1U84``$6Zxr*Naq7Ia5ISl$t(}hF!Ff;U`X9G#D++d#+AM+s2B1BrWMr{ zz8lP=`llOv#VF2pFIX9lcpb2}4Od}Zc-$5FQdM^5745J4NpGKx?5!rnaRh^3w9Nr9fwiY(Z3)b$pR+sQE%e z;n5OU*VRg-S?h?qe!n5ED|G8KebZk;W|E8Br}t z{==0ZG}g;&uJOZ{Glds0ShV`q8(h8RqKCq3#K@Uub_fC(IK6e^hcxX)!+;gmW2Eg? zTTQK$SfzUO?!+eWnG?S=CnUoW^&`MaT8|{bF-Ay@F^;Xt(_)NzndqCop!s&>+oUI|9n)lgkJ6nwUwHW)M@u$VO z6p1YyI$1HoSBw&VP*;q$T=@J?dGH>-0)klURE!lVXQ&wYANg#wx` zivD1nA7~g0wt@>#hS60rjEXJU$1#Qxdio#&gqdL&r9YGUF+X0%A=&yZjA>9=*$+kU zXJN!;VdU8Q9At$tU4@Ycbv{Y46h?7U7(2lw=|k)hu;Nfd#EP-F;AGjA>`l1E`QnR5 zfH##TW88;>AR&zQK^Vj7?QoCo2jhRT4@Q7l?H-Kg*!I8>{+o${-(bAE!5BM*Uo?ww zsQ_2ZB8Wc(Dp?ls3k$0(;#|&7vk1Ji$4N73c5weeHqX3kwM=!v4z^;3c!$PRw5iB< zCL$_t)|umrI|jX`L#KEuxF>yg{U3oZf)}^CJo$lVai)Pn$E%L+NpKy=825kT3>&2pD<&*!iX3 z8KAR(ZKjUAV^=OB2C~_<7P5b+B*ME>No9Rzwg*5mpdEL<3mMc%b545-G7_LpQvEA-*7BTwXZ`MgpOT1mg71 zt9Fkg5ZssiTWz?m29WM#Cd>JU)MXSv2g;mNL8+N<@_JTjm%UDF$e4eWOyc)#->>A; zDh@^j2jY+i6dgG_hCTaElUWcZH>j$$UamJ;MMIE%Wnzou6<5U%KedP~CA|T9X!v1u z!o%eNk-9P}--oZ)_m-fBMxZU7XbXJ7+<#30FcszlaC3P2e}zgaRe3~|mTQ|C0DR*| zS6@dIa!c~hQ%e)f4{Cqi^&}7CKg*NOR~j)^=$#_ujEOUiR;y!V3f{{xWN2{Gu5% zz*4w>!1XRYOKP4eW(P>qTpGb_>*OJn$I~e1mwevQQSzt~3IBif#o1qcF{)06=u}e_ z|45}!_1_@&I6JQYP*DI-FnaeQrNN9((+_ntbMi`9)|KK%?T;68s_F5iVQ4|3Tq%9G&+m)nLQ|UMJ8h=uij0Mm0R#6&Z?ZxCN(Dv27fxp@pFuNId2w zFc0AWB(0&L9o0CbmtL*0hF0_H zYHA(@j*x6ibF{I8;|pn%8N&|bn2wG&?q1Nz@P<%MDRL13AIVORtV zL;f!ePVc`j4EMB@jGQnhC0Vj3p~oK}Um$^s$m0%MA8``T!k}S%{Z<2jXBPMyOz*(} z;;^RE@L!)C#i%gU9{a%>k@girNcWe*@FRbrC~*cT!eN%2jxcO6ELplE#;BALoGAcU zM`cq}X=!9ocxiNj#;oDf0U-#%2*i<28AOaj;tCowcP%zcRw)0A`W7; z9?R>NEYg3iNFuC&r9Qv3bQMQ{Id9+W?-*uCN{6liPtFV(e}?j8+UKxuIQ@oID~*4P zhW|3rZTs06N^oFt;ZhOk`v;Hv%Ne!JWw5_Yc%1c9>MtXwzm(32^(;pIGFCb-h`+pi z;(HMOa_Bua>wLeABBYqdqcwc3)y8mz$$rV0CmKxb%d!IWfw;y|+04Kmkj1NrUztL2 zl`mn8K4T~FX}t;qIfn`I^^Zn?R-Au8kMGJ~6Qe=6^(7x+{Rjqt1$VjgHH<$CtKbj^$9#x&Cn9Bz z5JAEeezu-3*IHE#r(RBMJ~m&f537b`z8v4TVLlD{@(KCT9$yXtk^Qu=VU~Z4Bj#N?p|NKniLmQDCO_4Hyh_`(Cctqj^!Jc^3P4FH>Uu zZ^mh$Y{XhShU3n?j4}68x+B}n7T{iPzP)5Gx6$q8(b(zv3{8l{G3E`#mD$UaW2Cu! z)?l%hKPF;qKsZ0xOJp^7)3|@w>!lBRt2@%>&L2i!xL)cvzi$R|@Et@=jMAFx;eoq_ zf-Q_x^hi6?lD>IMk(h^YIw=wo8hUv~%C({gvdux>kvs$xig)Z2$wsu&u-*;PZskjb z6NJt4mlbsX$sKC#Bx44cm$Cml2id49M^L+6bkOiJ*GK*71T!q; zOWu}FG7}djgO^eB{F_h^X;u?Aztg`iVfpj@E@ix4d*3DS@t;ZaQ9LiR%F9v5%WQ0n z>8a-WS4x}-HsvI1%YT3B3nPV&^8V3jEJ4S~3lrLKk#vaPAqqOv#Nyrz7M~j$?nq^5poR>R@1(*#Wzh zEOYNcev%3J$rs5liAv@gpU@6>6sMavKoD$w!2W%~ZY(We8_6ws)G)A>hyM{mOVnkO zzkiMXR*RTPmG~|v^OhKQC+56`P=qL&gJ-s8_SrqcyIw`iA89+kP3Q#@T~fHg5mdRX zy$rfUPBXh0(4~J0h;(Ym`pG?@l4}mB-c+M=`S6$5dTo*AZZ0_zdV+9OSS(|$f3!xs zE*OuISU{i|T{4%xvBn6P%V|e+dAUS$tn(3uYw8mJTe^Z>xzwTjS-JfEOykyZ}Fo7=5z)%DPVYN8x!(%1abY= zJL1&60%YEipL1Zy^|r4c2)B5LU?kHv@62pw-I1zC#A_kCBLzzEsH{|5Wv1QLVOU4D zS~&=JB=MJ*@pwC;Z?_{!=%1yMD?e*^=r2UWALL0KT%#jS4e8Kt2l$K*MZz}Y$e~~L zKpTGw?PF|L`IHPe6X=zyc|(sb@QXJT=Dn8d4Xy_Ud+CqM_9qC9UV`rR9HyZ<+q1RK z(A)V8DdQ+3SY#+0Kpmp}IwgbC7{@5%BMt^66+(RSh3+^>Po|L2V15Oz`KB_-v``sh zB}fZB0X=|RKbW555E&t-RQ_BcaS)nVGNXT+-aS#IN_=BRZ$gO-zpT(hs|yZN};O!V|}i(-{@>xs((4aB~%u}KpKStetM@_6or3c zZx`#|v<5P$l0P`hQ~Zz-bHz&=bQky`@9#reut7W82`XB_-mIr^q*uPeGP@#%bmf%v}=>`oJy>azzgRcKS36=`= zQS=2p>u1BSL1RdH7`yGBHZ3B%pyz*&mQRBod@ST?(xB>*1~o?XgyN0-4B~T)uC;_5 zXV4wT=R`ac(z4&V3YiIMQCO(|g)I)1E)eFJ5QN5rq&{3oJX^x)!8!0S2BpBqs&3J0 z%At~?ySD7Biu-WD(Oo6_C(z11zm_trgfDnRPQVEL0X6Yj2r^$n9hV%udV_!X>U4Y_ z%Ye$jft|*Hp7ir!Vn8E@0W}65W`33=gHBb_Ww?3W@HHtgcP<7&S`3nQ%a<7vm|foI zA(7+_+JxdH_Va-!f_rBbqN_r6lG$vL}qqWYAx`74W&Qb_uv{r8CxEf)RJm@;CtAo`=Ho@;-1_^cYU7ZB(V zYzWvFje%`dEq42ixC)SE| zuIEEpDZoP6lo>02&WSt-#Lmb3bv~G;{a5EB;ma#M1{`*W9sX01IZJoS3h~Xy>^HTV z6U@^h|4Z2r%a{)wKxouCSlkdq)r`Zb1Eu7tr2tpDM!DDRf~0@ySP^w81@(O=5Nzg` z>nRf0|JT20kkMNAK(o{W>Z$HAyetQ!U~~e_WvxTvM@vCtLia%L@5ooh&pn2!vS5a& zpeI-1yR4$qCJhhwsP=d-ILu*pXoLh-f_tEFvTfzVE(^!<v)f~J-5!5b6JQv10vJNJ_oxP;C*AJ_5$Xw?eNmb~Aza z!?>-2m#c)aHx3hkq=x|F!U_?fy8((8F(g0|r$WDupCel9Fy7u#sYwpK; z6SXKj8gNj%3x#~B!#^EUAP`6(D2SkFBnrj@v4A`c`~iOxfB=DjfItucK_CcV2p|v) z1cCrMfd&GBKoFo2NFWFh5eyI$3VMCc!Wt5G7UsaZ&ZJr2~UuyhNU_)08 zkUmLx{YkhgbQT%ykU_%COazGv_XBx|mp#5Ju@`W`uL@J==dVh=z~z@J7YySFAJCZ0 zzhg-|h*Wj3FicsW+@e#kx#Z@vB@>^^#eorD$w>?X0pL z;}beYr|C;E;@cplRV=T_Fs*`kG3Id71c3jhR-t1xVw=?}-mX>&^b!-aR#}ieN(yP+ z6&Y!_K~i*2s!omO?D(ikak2L;fR$n`ycDoPadUr76 zw9tQ~nM$V~zN=`c!M?0ydRiN1B_a^HgQ#dECVpW}HoAXuvqC@vdya~w&{^4XCQ0h7 zG_12ySabJ@krm#r+xe_an%)m$)1WMk{fAnbn0Y<`y@~QaO#wzkD?*cY_fPu(wuOkj z;%Mse6QtD}t-MCi9<30#?B;Vh$GTo=X#P+M=tj*QxD?&1Vj6e}uRMZNu;t+u z?L@gJUO7Q6(`nEUXZr#jTHHsHi5!2xdD{Az?-BR&-vS?@4A4hT!a)HnPxjZW83-PiPgFx5=3yS!CK_n4_;Tjr`p`5%FgT)tt&$EB5J{&Bo z2Z?tGZYIK@?bFNxDehA2$3}qMKnU(+c8AOE+0o?t=v$ z;@hy?zmW%i!y+7p^pjcLl;wYQLAdL%ke-WF!{M1x!EnX@Nhw*lg_GidrK{-m4% z@#C@wOl>@adx)h19)gi!>cr*L-U2;8y+6eSFx5NjIx2dBr^X(2YCH*hq97d|#W5yi zca#>Yqe&%0&Div#%oM=|d>*~f^8e^2PWTDN9qr^?of+0rg1y+(&JcfRf_R@6=WI&K zZ;PWQ|BEBz*yel5ZbM5+j7=iZ-9iiLZNIhh5q!jjem>+z{#6!vFI$<@)r@QB>f^uI ziarV~q1bo_+8WKj&yVM@Y1#Lj0uJV`w+<=iJpSs=)80H-z%#;147%qL|AE%@SBBW0 ze(7(+qGZcT`WwojQ^M1eRKKO=v}LsOg(D+bVXk0I%? z$I~|)h!kNQ5m@daq*_>SlJ{ku6$QxHzO^~jJ02NV7s^WkhX`^iJN?%$;ajK(gvbfX zi<*TpL)`9@XUKMOT~(-=hU5~fv+i-leYe5_#>=`Yu4qT`?8b4)nrdkgrfKzW4BJxO z(q42dx25#6^p<~4u4=ec+B;l&2ROO(sW+cXjf!!_ZNlZV)7p2?#mv~-=&V-su6?aI zsn1qCz<5xI0bYL80B@*Nkq(*Kw{f-mo;$jbPIr2-qkpU3`z%Ir+yz!k7$Y9pXPNq$ zE9T4og03XF1W~<^8aV7R#sRQ6iUU6Iwa2!z8j!>VxZ4 zcH*#23?v5TP>o$L(u7*c_r5vkMdZIuw3ejezu!MEh5dP1u9%?|d{U_MG6gnI4BhL4 zCI9x+e;=AP;k|$)b%RoPE zl|-X=X}zq{^DZ<~SSy`*m)cz9saVv+E%7dGh<8yblY1w4mjY10mi*ntxh>HE;%MFO zk}e{NP!rIob1=53}+H7k0wGKE=`+zNka9LoX?QecvRJ z?>&mcS+ONxaZww#hsnWimZ&z8Bxot_X>sY;LO8h>V?}XY;*Th9_xG3{LUEzc z?7Q;ljjd}ZZNx=a;=)d8y))v{`%H!{;+B5|Fp1Cb^*O9I$Zte!%Nr*%-v zFa;S*PJFMU!OG_q?DSLZj7R$Hj95z861lC z+8e>%z-6M`;#I+EG9ScEY+L^m%U#~!uW}(a0zxfI=-LK-WWIy0Dv-R8&5ge+Cw|F` z2Qlv;zRLG)zI6b8Gh0{Wc3rWx@lP6lYjYs9%tl+J8g0+=xXfrvu8^OXGauQ-&qnVM z$iXn?JGU2CbEeo2Y@1~B2+MzGxrta2)hc8xj`hw5tjk(0kDPTGwat)$!D>Cjyl$N> z5^hvn39a8vv)*}j?D^#8lxI3KP7PJhoqYs8HY*_Hn4~dgJ4!6gGxDBq2IW=T227mF zf*c|5LsIMy3oH>y0O#KAi;ELF8GDg~=iKn~;#%^ATs4~CVV(U*PcMQEnuY<$;omerb*TDnmnS>m+azUd+|isF;Ti$V=Do^jRqg!C8fJOdUzW-psB%f-k8rU+Y1+-8V#B)gd>>?1x;y#T@AmqN zuJ>n2#6T@AlN^*Zs5SZ>rG{cBjLCF27>$zDYlJhnys6vL?OcC-IV~>|?hUWxSH3s% zxR-ydoQQrr70i9vfafF;8oT(Zo=(gu~# zqioe`$$-(8#|EWI2FZ=q=?&qSPSct(0WOH#d0AJb8J?wys84cj&X>-^3qg2T5`c&J$tMul-L=Nr0q<$%o*V?wR($4R1Sn|{sUI; zfavR;PP?y^qujlQMU;g02wPbdLLu#ENn2lgjQER_8&EfY8}+2nH@e_ufLu3OOl#A2 zdYlHm7MZ^po)M#_pfg_B58ui>V8|xj6Lh-A>3(d&-ht@AW0Sb6c<1?xO{QyDrRkFy zb{wU+R^0_BesZ^9p#W#hhc%i1TiKz2HHrBoxCG6s$!ZKk(m(ZfHHrA*Uc^u`(ocj4 zm`s|iBV=8FJfB1q)7-R;*jBdlb7Oy+0lGG)I0?ejnY?_sW&;c6E8461APUo%WL2(C zXn6t|pPAqZA2a+CkjeVUA-G|&On8PZ?w%u z{g`z5{@vtf6>prcW7S!nVq#24!b47ozAjTNne0Y?rMAs3nN_8PU*b)JY$<~ORgLPW zdVe;x4UP{;X;kgmgS~*!sQOP(mKz#13d%7Rct(}}j_hwnWiHz0HKVpPqn?oA{;rn@ z`XmX!lOzTD3-)Q+fV1h#!NY=+wHAjQ@fdvHb!#E7166{4Y@!9?7$L5@aCgujh|(j< zV98^Dy-kzH{P7eVv7&j+ZqB}P*ExH)w;NM@*pSV&$!(a-P<{cAAuCw~SxHuW0n8pN zX;vaE$rkeJtC|_*!9$2d1q@7R=ow=LZ;)lWa)+gq4)B9O;o?U+;k>Ly@A}OPSBrfu zSc`qS&e(9Q#Z(gS0f6AJn8yOqeMkflW9`9zhw^Pws?Bh{YEse$uJ7j=bsr; zm-ty7F(lIrLN&FIVzgd7yo#nCx=84|v@^5Vsn)dzhGM6ZJ=NPHQKTC)0#P#~;VHU* zx2RST44PGsEXG4)6#arI|4%2m#29rxqj7W-1kX7;Qrk41j&hG%`T@q9R@5X00>EeTj&uy12GUGgb+gr{Sx>D0R#aA z0R#bp03kpTj1Xu9R|vvj6vAQHPY1St4^63B*AU~2d!(e3yoQ}_TE-V3OPm0YRqG}i zDJ#eNhwY{gJ>Q50QVONvh@N*7T{90#8Gfes>B5_S;dbhs>9|@)kvDOo>3?~q zL#*tJTIfw5w`}yLBTu&EtHE%!LZ1`@YQTM-fTfvYZ>AtDQ*V2eepTuu}(#R_z zzE2-f`8Oe%=SHu9^xvdp4~vT;Q6qqpqvn4Nfw;mTAJXvJE{RoEQlN=5RO#;iQfbPE)~vXTcV!9pMzyX0i8DTBwn+ zsQ1RTQv}0lgM0|-z2DY83m8d%Mx+ei8>iVg@(n5UFhx=+M)QOe)f(+=HFpwaFM2QN z(O>AmeWnk}n)N{i%04K1i~FE1zYmH{{GjlaA5${bB!p0Vs0g9zAt990 z5<+QnLa0Jf2<43mp_Q(G5DJ_YLJ@T#R3`+6&~al3Wn_j>7Sj+a7#l*ioA6ig#R0S1ZCG?NIG%o3sCWFizUPK1W} zM5tV%2oNF)(E~tcpnM$ZQtAuKll~BF75=!PPp|FW1ltE<)wHqy=eAW_5np;Bo#3fWOx`dLs zOQ>sl2?eMxp}+wqG|^x}g)>a3M2QInjxnJ`j|r7cGNA&0RVEZK%!HEJOekWW3DqVv zp)isr6xeA(xv3^pq1J@*2Aj}Cvk3*wHlgt3Ce$|GgckZusCU8%g{e59`j8V^YB`~r zb51Bo(FrAvI-!iN6Y89HLP6?IC~4pc^%+m7W#$P*Cq1E-u_u(ydqOFbPpCTe36%{$ zp@H@jikN?YLfHf;R5AjE#yL@ZQS zbwe_Llc8|6y`F%DSK>Cps)PD;@yqQ|SOue*P;|H9n_{{*;4`%|$2YusAGNVt* znXreC%$Xn+fXr8F4le9B`g+5PmXByK!`<==B_xM@|StBq#1or5e zcv43Pv7Tw(2r}F=^_#RLKvT73Cau-Q z!GWd{2WLQ8KDIGv;@ryvT5<8OhtLFKQNk^k@VVWv(3H?Z(-blDhzBcG#y2!In&CZv zG$m8whiFRKV3TOdjiaAv%0$bCR?#$JDOb5glLz+$)YapW)((WDo_k(j3EcA)1g5CI zhU+l+^tItfOj@7sXu6B?8^eKewS^KOKY*x}AVrX-6R|xbu>KB4AZhZS)gE^#%Vsc- zWM2f8Cr!LHb#{(dhnJn<8becOYr!jjO(w<5H=HOV^p>W6Aqc?WUv(qBFio?P64SID zk8Vasrs?@-oQs6n#>Qh|Lxp9znsrin74Zi{H%$#H_kGi39&PLsr)(M73KonHwF4f{ zqlvGcCWt*vqrvcL%9w!l^*5j<@8z?`@iW@m2m$HqNE7?x3Q?1tg6v8wYLfqdPq?6; zQ4{p_Z8+rk!$bng~drpZK4 zM>U1jXzU2k5)4^bPcNV6!1%9Uxdg???j>OTu`Eo-_7 zv&}QtT|JfJ@3l+BwWh2(=ZL>l0&6v-6Sh;BdgEuL(C}SNoCvVOcumo-^Feqnw7n+n z_?x~a^yLrvH9?H%yTu7?!hzB&*u=c;{+1Tne$M|M3!9RAz-TcG67^1h_FXl4Lhdm2 z%UNoNj!h+lMF_G#;UL zhfwkZqUCqu_1e_6_7`q{DAEq+nBAJi7ml5Y<7lOpASZ3QJ?ZodrT-{#dYkr=)TAdc z1dcK~BjZMgDh3=%*f5D0<Hn(gMl1OZ?prgh^w{M1>y!#XXt|Y*7D_2jlWCne zwMT9x*xVo0TWJEKA)zL3H5#KCkX%U-Li?F31#vw*(UpuI8pmW(5z_HadG%!tFbSUI z<`8O<*T)X5=dLt=Q%!MqB@TzB>b%z1P_4i#9SKzZvJM1d6tQq-BWEyY7tU*qv@-|O zT9I^&ot$g_7NpqE5Ixdn)+jR`&ij>w_$#e%k$iupkbkAkaSjUHuSK8Zv9Wr4<;U#p zasX&xF?tRCV-jEsg3n%eJy`NTSb7F9;yH-*r9Nqz)DItj(jg52hBMn4^+lp}Z~GKA z^nY;1C4~-i#j;|QC_1kj8ttEU*NOUOe=mTm~0Z_4}dxhzlLzT>m6T_|?x|rJ%MvKED zhjM|0fodE|pl^vp6jB_jY)JhGBe<3m;Z^ybFmn7T*mP%nfAHf!amkP3(xZM>>{kdAwQa%uEC;d5BNL_V)Q_3$|*U)sy)l&r*}UkMd0Mh+pwUZ8Axydzzq3T7gR zN?cC#8D-jGefRA?Ilmfpb5!)ZTLGn|Fo!`ZO_uA{stYkb=8sX6N^l+mmY@KH4c=8M z!MZztJxe81OQliT4TM}Oxzq&erIL?cuaP8~my|ud6djcJXO+o1s@;OsQ;K6aG{doV z$!}zWo)tX=9W43{*&ZU*$C<^z0mep|*TdD3MA*lAV{ZF=kmHMJaVqB1c#VQIxJelxrB_`4*aQ6Fe zB|;p|vVS-2Sh&)MME3*K_fkivTxp3_-*Q75X}_WnRorDo%G%(Cc%<=w5EVd1*}Ir4 zStvs>zR3Ly1RlZ_vVAoFLA;p4kARv^PZD4%L!V;wjAWqdk#c*`hVLyG=z#;URM#DU z@&HSA1Z5VeR4H{~B{rY&i?mB$1**yUMQxqYr$B!zgj;g8pUB;~W!hcJ_NOCDF%%(an^TDU7adh(7PH;Shd z7ev;b4Xkfz)Au!8c_>u1-_m4)fc};?P)WDK@RK?9OV|&j4O|KjTpG|;ezk*3GrnKn zgiHNl`zKM#aA~FC(kuB^3?MFrLR`XuybcnVj5f71#~f;00;%d`j?}l+^tdE{BG#85 ziq+BhuL@=Lvv;xTMY$yR(^FWdpo^#T{w#Hyy<9r$irRwSV4$+G^@VZq#5R{qfa1{0 z{{`J9fM0Q3;+9#a9kTTLnAh3ja4BtFJ#%V>0oQzCtSuvbp$Uwg-GuKw}IL~zPZDbARp zXCz`?+`GwDa6F%q>cz>zdLw>HQBdy9$iMr#)@EdQpb{EErC$LKWwwhADsjQN$&5Le zXMm=$j{#uOzt$4Qx*3^agrTDn47^bZ4yzxP!t0ilkvWwPr4qMl&66d6sJxvfy^eal zIe4!xm88IU8gybR!3?p6&S0IX#3)#JjOi67bG?f>CdJbT=k=wo?n_6(eqNd#m1NhY zn|xezQ6-mArT-koJE~NDR0&Cfp0*r~lS{MMoKmy*g3@Sgv0qhxrK<&{k$*h_`l`|ktI`jJSXL#{q?EUb`mIV9^CW(;cmm4N zzGwtym1xZ>O|k=?>^LE#&4BVISAfVD!W|cT zzO-~)W>uzgY10j)T?q!D{*0!BCIKHVkN6bgG3ey3BV!kp_EMNB3+#SR0@S(ooE5~j zgl#j@5HTgKkwPFdoAo=aAgoC{*PxNKOoBoe53 zNIz-zPLHgiwiO(8FD5K+o9q2^3MS7aJxR!J%41RMe8+m&E44Tp?9% zN!Cz??y8U;qBkdEYJ_USn)I$VRV1dnxhxzgbt4dqG6S|2;6Y_M*3Oxfo9-!bLASe;59 z(=k|pY97pdeLqgnJcFN#R{){Wt4cLpy_`k`7y^xNP;0S_$N>B!ybom4QF7Xo` zgSBQn;BR8Oq)f*MZx)-eDF>(}y@6Nk#X-dY|Cod04ajZA>8nhBgD^6^1B1?HK4yM$ zU9V2@pGDa#liB_q?y)Wp3d`iBNt%Ls`Um4J(;N`xv%qtiApaN_ zyG)HEb?!1*Uh>c{C``%17%?4ra|8aplsXwl`U?DP06`4)6~XfPjEN00IF73Y}o zHu>W|8P)L``ZJ2PTE*-!(JP+dfohB<2J=txDuS!&F+I#&CPt2KWoU$Wxy`QncLxtO zA0dH@w}UNTDI8)XsXBIL+gL`oW|C5_IKWXgml>Ozyyz53fZp_+4d}MJ-@OLDK=tAXp)$fxWJbIkLtWbpFp*=N{UV zD+bJHFqzU`j>f)5pDYC^%uz#Xl$ayJ??TOFj;u0Ae3_#blFf;mSrx+K)4W~q;`qgH zOai$%Dnr?H{pN^v>|ho-NA>J+bB?qHWIvsw%hYNA?i_{h9L@iK(HytuDBg24QJhHu z`#CCt;dujeWao!}StKeV?C}uOHxs9Daw!juBGbByDMO?qJe?+}I(M9o5PyJCR`nFO zHa{qfq1Q)=SCXJ7MWlB6M0G7@?xW~Y--xWEQud;uT7Gj+yIV)WSairdfA3XO!eWUb3BoZ-c{;n2$#><8nkJv#a z;h(WaNFSXf=jV8|@|<26`8~`a@@QFyPbhh0nw=rg!B!xDEL3^Kw+g9Z%Oh7TKYD15 zwn$K|j?5#@ES7$6=B_N2|HNI+b{=VGQ`Bq908072;#Lj!zdSpA(^nPoT z$`^npSt1n;sOgcrtvNl?<0aA4BPqU>f2l{p=lC~R5=JgokNN@buk1Gh>ye1ds)K{z z*q#Sg3k8sW3xwY*h45}nq6RSm#LV0Z0N!pyV6lHu^t z2Wd6@-c}%m<$&b_ekUOx5dOu_eGtm!x(@9g4I1nu_hNO^GF7vW0>dMxee~Htbe#TT!p7c0#;KwoT#XqE zSOs&MB_zJ?dtez=_qKP+>@#ifM`IrbyealCQJBDPI`DC+|7fBVxk907SUYzUnx6Ca zxs4xxz0Wsj`T*4OjAy>4Z5ANCdttGbU@7#xP7(TXXV?qXDIme$2uxS40-`@4Sqm@h zO{{*VEcBKJf8?H(sniFM38E<#zGfOf`@a`}Y5$Efd@=n4z|`I} zMg5Qy7m}190UR(n1CAIgR^%uHr*QiSJtUqid<= zDku~R7zIVd;XzImfRDgww*~UbAmLZOTblK!z}IhFdf&I>vWMCYAB@}U0wD}uPZu8~ z5LP5r%)XGM@r^4O8fq{t;R+0R5E70M5?SB%ZPFnmfe#^pQLOa!$jsAiI9!AjzzAu7 z>7>9kLgH)1lS*;$5fW&E=9I4NBUU-a3uK_m-8X-r(E7^3sbf=aLMk}uWKlaIIi8T{ zEO6M7M1(@ptyVxqA<1a!Ax9x?kU~;G3?RKrwJ^|K@W~7~2T^YukP-ZnvqE}|!K_L9 zKDeIdIGM5%IErS5bwQae>{rZ{S{PD)2>hIx+szSJDb0`y+8@5m6uUMscjsY^>-x?> zTXc`23jV||NNq0o%p?w}69wcJXKxj&1@Cm859un&bQl16NUE@Ac8>|b zW%s#CtOt<-gh&Nkt+^~jVizK**}!y&WC#K?B9ehi6{Lt{r4L}@0!E|`^}bYpZ$_j) zaZ%nQQhq2PPh4=$Lap(#RJ_oZf&BMzkyLiJFOi}Q6onyynwdz!nMj0`dubw#trmiL z=R{igP$_-0%Hr=D(ZP)rQYSEgrG~U)$t36KML{4VslGWu{+01t(|$(U8$i|SDH6-9 zD{1tVRHP+&eKWd5Yamp^G6qk7PFVn9Ctu&L!%D|GA+^$}{qPs0MGA+x{)}rOTcm@w zNEUE#i-gHF1}@TaJ?9j0krYzKw04coMdCQ1-;35hGkL;D7*zPY-S;_?vU-G-CLFPd z=u;Hze(j`#kC8+mBXJ=k6^189M%u_Iy$F<%B4wnqn?1^DPjG*;4xq??RXo( znNYO9TYOG^q`PgyDM$QBqW~Gq1nNh6_9MZ|N082G{d#C}Og-Hk1(ICj&a}GOC`Hl< zlA^?;EY!C^?oVb5VWFkRko2RR^zT#`nVfNYGkYDxbK7k<$&Yz|fh48nO0H2z5|?A? zkR)|94xi$Ln)-pB;!rR+ElF}uJJ=p^p6UyLyPpvy368N`C+ckht|K%`B#`Hh!hDT; zJvKe~rm!=OB#D#|Xh>i+NoMTFLsIqeV%sFeSoi|BS-eV@2C$RF+DTFmv-HiAv`0@; zMzqTivTa=aBrPw0uY)qug!+02CDBP-a-$caBj`m*)qtpWW}fWqk2G6K zQg5X=Q__yA15wb=QK}r z5|*yCS<7>Ocr+Gb8pC?R_?bFNZwt)mG?V@ZfQx63KYD%YC{~kZ|CmQbtw{pB zKntbg`~&>2N%4ljRoJA^h=HHjq=EGi*`#K~CCny&afnl(P2!*T!o@spkZ*0$H)&{6 z)a~aGg_~4Ad*TneN%tPe&9Ot`ZW0jfoxDk)x>(ESQ5Sq0AcxF1Y0s?=>Ay*dFO`!c z2DZGV1TLIpuHhR_8rXWR`pYa1iO(ZPagsd@my2X(QGXBrI7wQDEi^evhPMns)0gzNjscD&jl)wBS3|fehYTkZ`Sm(V{R=DTX-* zpqDD;avvcAM*TN}Q3(9CamyUL`b>I8OZR>JV`Kt0X9f(X@(O z9aGq>Ga8t|HRiOkbJ_n%;$wGJVkOGwwG|**Vyu*#oyC~bf-ApI$ywsXF!HPTtjean zW(g@aGEk~sFZBV8HvkxEX;XrK3Fsma1ZH4ZFbOdb!$2HG_5%_~5EubL5CVk2K@b8W z0fG=91RjBckO)G-D8fl+O9x6r8hWAqF}m{%LHkSNiZ8v0F7K};jHCY#t)fez-+CUW z7+n;gpy!EM3L_}U^8_2oE)sZ~7t!Hakrd#}S<0TPhIi*;E3?{+TDcg126q{<(ICp3 zjRDa3nf8C=uI8p<&J?@ih|$yU zMQ!DeV?4Z9So1>!zp$o%QQV)ljD1@JB2hpt7DSRg^m(VPO;t#D3pOLG)-|iRpGi!| z8vch};ph_H*-_D<09W<{2%Ta0fDcbsbx=P*U})X0yNtN$;y>cu@;Rm@KtF<^LX32Z z>k5;g397kcpr2jOR>s`TQQ;ii3hv+}o%ytD3Vu?)5(KGTVrw~n9wxp@;usz-3=CZp z3ldNSGaCr~X1i8b`k;};D@xlnDL3UX^_(fjki+^!*3 zfl1Grzm`d9e(}^JSB5( z`PxanjE1`Xm3r~hVGxI$0un0DSB#J`^3%?0kaN_3;~Dt(oz<(}K)$VB?E=~7%`XVx zSFe0RcV3@$WG zS4<+vt~f4}T_Ha#yW)~$b_EdO?26{&vn!%Qv@4#C(yo{%s9kYoR=YydxOTyq3RfP}d#8j$C%h!5$mctWbXVu)aO z#R1vw3Yp^F7562)D}V`kSF{%AU6C4p>RoYnpXu${&GCz$h%})wp_f0ANiB|zKrjv> z0E+Vdb`%hmc5{HSqE-Q8@qv2WsUAD=lr&yoU5*gP2MYEPREzSX<1cROS7~9fJK+;d?ns+@+`{liN#C z;xoJ}AAKU?UxF*q7b0zQ(1am>x*tCsl4*+gyH*cKnxHz}6h-}RBAlW%-d2=TBw*PD zC6G=LX&ZE`Qv|ESRNGZr5H^8A**F51O^ZEoz{3^ci7O(3$OUFpx5y)UFx>me;;wj)`Fz$cnFs2=uX3ml;_q7sSaq z8dh`Cuvo_vNN=kuP#OEq_#JzunamC;mPmy#2o{xLAzEeR=F^qD{aN3}6GVeSdd9v>CC=$ee1)rZVYvQ51E(o%7mig(vLgSc#_DLU+S=~j? z*Nd<5eW(9qmom$y1h2xPxh=EI3Ls>X*OkwJGMc3^$lXV?8oJdo9Oqdwj;NKPO`gi~ zh{#5?!fu9d2C!__sO=?QsWpW-P3RbSF)#%)If#KBMXBLXLXVkxvouQj4t}%ZOtP3= z{Rk47b(7D3nEatxYhnh&S*w5TN~&b>(@cA;DW}irkn0CiL?$X9Zz@HkhbiLB4rqwp ziU2Zb#AHFRtCjXTq7Pjmdim8_(3m5nZphGu1p_cf2f|{oZMNI(0qEj%TY&2i{w1lb3n!ZyOs;r*|%-HPHNI+__1QWODL< z%%-J(dJ6iq;^6EB`E;YEU~EU4>9*6d67MvqB4k4HxuAWI>;GI{9;nsR>O7g*X@_4y zgNDvK8~+ce1sv(a0C9+*Ry;e+=~0Fu7(pkn=!wcs+{*kv=tE2x#Ih`+wKiHC+R=M^okanxqoq?h{BZzZEBBxj{#?KAo(%rj+^=;^7=gbQ z0fP7tVj3LSS^!etZ$oD>7TC-%uS3z3YWDnm+t_-${LZg@BOiLsFXA6t32)#0>OK-Z zUGuAAk}Wq|XMV^~X@JK3c%-t`?A@1tpXZs(+Lj+dUbgnUyYed^0e7476A7BFz1b(f zrd|AA0@NkHzqiq6#{kw=a}kSQ-|r|4ir*jP+KRd*@teUdF!WeK{J6ot-Fc2JxP>!P zyf5eaz&Z5pV=~5ihtT(csBp+FE}R~nV?I6Yb7vb3OA6n+A~sjgJsay|NM){OY%;m2qPicF$;^mH^2cbDd0 zX1#@-B*2!gf$hQGVnqa%X;$@rG(jj`ng!j=-?c2Ht}*t@R3FQ(&K|+n2!0g6RlSvx zx$Zk~sIsk&0j_O1Z)cwEgxF3(r!!Mp4Oh6JIp_u#vuAMUpLwtyT=extqwFJ*@}5Ds zn5uj`SqZdau2!U>*BUhh%s>D*X>@imWo~wYi>UG!4l)QR5DX9qqa25SaU6s=I6oo~ z2m}Fwpo9=03;{v_AqWB`2tt4$1SNtX2m&KSQEITGq7K63*rI^;W}0PvQ1wI=hMFK3s{#P6MdXaAEw4+O9Oea^bn z5Q)kQW;(NW+F)CgcE??NaJR|FXJosHTfI;cdebSd;y0-xfxwx~lvQh(G#99=Q&i;} zfX1E%X<>Xe!nRL+K*|DDyQ#?G9PjOVgYAh%V4UR)z6Zd;Z1eO)9E_I?amc|W-_#M` zIA2IMAFG5aPGypRoAA#2sh9(cNSlgt0PRqz-4^tGsSj}!fPP$RB@zBz(BAOli(Dcj zf{`xJys*PJ&54cN1YAsLQsir#GY7k7O*&@4EwshaGh>`CphaYir`iJI1YgTBqKXZd zJUN%R5e@^nek=s(X1pu}>JtakTyzsSCgm_3IYo-t9D3h>8FOPwu|MwKpu?O{4|C36 zW!aC@Ktym1EAJ;V=7@>Ufgr_U_rDw@_NIaRnoivS{GgibtMHj3KdPL;*gUE~5a z$gHVA@eO@;=2(>q|3epq79ot+AHm0mwYqHa#Qq!ftz0}2bn#FNJ%PJ;h^LO(L0jk% zr35MjR*8InhXAvEqE}6Gp^1OC?`C$a5|*ip8RB)u%iiciRj zyAB0bWCd=m@QK40@qAbODOsT{St(+hr6wzjuA^p7W6x_4DJ$Q7q>sSl_BKyXaxs$pCHYtK@bvM5QU7?{`cCB)_jzGH;Q?2Snq&6@&#mn zDVH!|by#M8A9VE}2)dxy-P#)3He;@d<$J=`!^F8hD86_;Q8qzzZGsR00-s;X;{+L% z6SNz}Ds+OvgT3^?jjQm{fdU*gTpv6$4D{V8c*_`LvL|56h252rnqPB4t>hbdKy-IO z8BpAKk?PG4v79hojewVIcmtph_^xVygHU9sf5M3K-I)hrR4azh@OdCt`2YIn&-H*3 z^G8lT({lqg{gH7-csi-z06sibCYxoEPpY^gNMpc*St0|65MwG~gR0$RCT>CHq?)*I zCbO@;gZ>N({ju)d528P5c?z;do>T59RS5}D$_@=n!csXmU#ZrJ9b(jgE?AL&3t9D0 z$OdONur~+Yx@Iq5Sa}Hs!DtNR%TR@s-E~rg@rJKccJk9@r%5Dmqj-u1M^-UlHAZXV z{ffJXR_(af3KLg2ttB%{h|$~9gP-sg@(|g9sA9**R%^FM-Ai$#lC-6a<)l(6$bG+0 zs%ye0_CKk3TS|svl|$kUIWZi6JUe>fARdG1he+g`+M4U2|I)nW1;f%|dzzENs!$ek zwouk^9XZ5qYGEYZE!M*-J~sa4Mpu1Dfr6M1zzQ&J80f7uS*wQSz1)v(hG@#6h|8`T2eF7IGY zb57^98AV10wZYFb(sO34$0^{hTb1`A%cBuk66%wS1o_E(3-n3`RUNu)ATz}TNS zpJFA=!TP+~C4|0%#q^9^9xOX9Qrm-N--C7IgZ0efc|n1>mEp0YVq!+3;m6r(vQM)oEV z?Nf|&^e9mf*||VU0g@Xh5-`3m&h94r#^Kzd)Fw+ccJ7|3U7un~u$=jAw1XwfZ|f60 zlJH>Y73%3+_ABdr|s3Ji;44tan zGAuPFugrRXD7BbEGKSeMH%kn48ITH-QWxMEn2SR;T?G+z98%K?<){pj$~ayG6~mb} z(a$eIz_+1yIx|6`E5BBuA+!&O#Ewc9<{!q6D3)7t(t?1(fH{Oc0(!X|`z3{7R*=X& zY)7^>$7(xjR6Y&53Pqr@6uFT0%uTWbZDkC_k3gr#JJ|rb$ zf5;CpIXgE5wd|4T$)&&c_WY|oDl54;Z9%~Z_XuU1X)}a&9e_a(n=_+(daW#k2?)?S z2sZeQt<1OzyXZ4-ZZ7IhGw=Tb$_>S>yzJyMgSw%Vcde|&c=SSc5M2S^6TavdQncn> zF^M&Q2yNwVt|1g8^7WpOfuluw`w8`Gb-q^Y>BKr_Fr(Y$DZQ9y^puaDesD*khj@$f ziO}qR0U4+-WC2YhvF?A~Iwbl}G$rgI_!Xtt-#3>et_PtL)pw;w#B&syQ0(lB)t$q)_`cHldwRvAU5uR-9 zb$_SnF=)x<`4E()6iWG1wzr|3yxut!coK1H(7+4}Xc*B$IAw)g($=z8$4BtsG^-$W zYq2%IJDcL80a)G{VE8q4p3d@HQjiLNby_72st`iud*^9N=L1Vx1)+R~HG#eW@;sKr zYHwBK2XL({^1^?EG}m4=rR$G^A^;`ubhgItn*{wX<&SobC@SL94TL6TtQnQ^v(gny z%R4%66@Q+Z?CreseZegE=I&re#Kb&_Q7zZGk@{OVZ*cLm#1n36+Dtgw5Cy`2;8ZGt zd{v3nz9;bFL-}?lt&is8W@M0hjv?ZnRhGLP(X+SVf<1hLJ-zouEZ==18T>>9gNmBd zK2M`6_fp?gs@~0O*t!p$%PAuH-eoK_n*9i!mr4VNLy0m6AuJJL&QHX z7-+z&;N!h|Xoc9mx8m3$E!z`+EF=I}ZEtoga%psBWps0aWvJ0h2O$W-2n1miF=UJ( zc^(o#AP6J~WC#=jgaAVb5HdkRfDniT2!ap*2}7YI;f{_TRI~~?IVinbIg#sgET`w| zW6X*Bhm_~EX&?)ovO}F_I_lZH{sTQFk0arB&7f;m1S%9%o_A_|S4km%C@qdAf2SPi zwFW7u*oxyBRIjm(;c8ofe`Qb>Jeh9>tIX;R0CPZ$zt=~x;Uj_t@njjn6epr;mu2KZ ztZ%asFW~5npm5O)+wOCSDX8gvRqLmq>mJ%5gKa+Py(qC9GZGovqD_L?VFkpnF zn~d20@Yy+Ngfyhw@;q#WKD61h@tQny;ml@+nL#vr3ay?wycqS1_y=l)4r;`=$#K&} zjpjv-D2iUK6>(MZu)_{6iW-SH4pcq^rCKwLv=%lu-QMs&&GK$2^)Q;mf96L&jEoge z;v1FoLPv}qg=(NAMjCRDT@!27*9^*9uWnif+_`fAm6m^+JXI8%O;3Li*;7J$<3!Z}R=*`h|}73z6BU@qz;k z{Q`zciy$T9{cjXnRg&+yy{1>s319W6f(YCm3&eN;yBYEijH^YIQ=cCeQD0aaw`#a*@^1b70##~M^BzDTx=6^L<>&6^3|Wx(Cqvd?$a z>>yWGA^ImS*>K^X*4r@GnBHKh*SZx}L^i^Z9KvSk!&|8m!_W-J6w(88M8biMGy@`9 zrY}zDH@=8nJ=;Qse-t{P$hXk97^LZziCB~ho#uLb^eeXEK&sH0O>dy8(6lk>)0ooe zOCYO2p({?;G<47DglrZQp)YScp+o$%6M9ex2g|)Dbh#GlKIuw!g=Xy@KOs-3E9B_| zP)PQXId~Kb%|&x+(qaOp7f&y>9~g^9)GZnjk>?mjBS(73e^{t0i)FFRqQsv^VB3|D z{FM+qJ>z2u^=M`wYrnOGI=(efz*l1(CiE<%B`8{7fYDLej*jDRtvnpnJQc3~UKSye z79r{nvf?6i)su}YppeL9#hPHAe)8N|pbb*G}hNr~~b8 z1g`%$M2XTMf4@-z{s(;k2|7 z4c!O*4gw+Q5+0E@ z003QZ0BdQ*E=M^e<*`z!KbdM%UdtgtBYPX)8EY@Tjw`>~8~*#41l?y8c~aPm;vOT~ZPhRm;RR&GhlQBdJDc zOvj%gv8jh2F!~RD>%YqLD^>5f&=qMDAD!YewX6V2s$Z#m^{dpttQK99!C;7Hkc8_= z%JwT-{{#^z$NN=6HwarnjPsVUkV5x@YFbNve>h$F>8JxoP$eVMk)g)KherkRyI1-4 zkvt0ZC}~nl;RIvx{qu0xzAA{;tMRoA9!piL-}WCs?Od-^Kng*tkXLGaKL8+Z ze{FCqf{v)-KOztW6c7}P!yrd-jB$>~2NFOKKoCF>KyU~Wh5$h*1R($t1R+2O5|#iV zKoA;1D3mm$l}$Q?8>{(UXl#nMX|@UWpy=oqJ|PMS|74gP5u+63T|)kbP2?O0x-c!M z3fbWIcfy7O7>&FDQBlaBa;J^?lh1Rre``NTH!wzFn)zM@4$I>tl8Sl@F4bQ{tqr;d zWCV^GJ}HT#^DvSoI%!}r9Z^3)oLbrk-_ZyxcAWW@jDJlz9dr1EZubP>I|a?ha;$^a?g$ONT?z zte627<`-4#f7@S2G!B4} ztiJ??ia-MM#0x;}WH(1;5c}eD+!pS-H1;7#jhyB7_pApqh=OjP5(QEpZg{_IHi#~wV6 zFpdy}sE+G~I&%T-CMyy{%L-ia+s{|wRg9yf02R_L(*~^4dikSP!BOL{4HvF9$6y+n zy@)aOx}x{Lv0mFnZ`duojwBL1>9pPSr+Gbn2>u@|w3A3|!-i z6>Io4J&RKFC+st*aRbNj3+CT2SVoK>N&($i|MAh&nOWtL=+Df8fclRfO}_!8f>0&? zY(uGVdR*@ZUc8use@py;2Z3*#vx^0CW`bGm{IbCz5cHfpY))n3w&Ez62>x7DBlGih zYmh3&WotA@6-EcN#3A@KnEoq}Mv6gsx7Q0B^n&Xo@94rJV)AARS^-O3DgUPmPO|~+ z_z1EKa>e+rDcZUBF<7Z|gHn>3yqQv`y8@a9W+sqPw`4WZe}CgP3TO~Jpp2nCSB)M} z87`QLk>uAax-UN`0>+k!<6kIr4*Nt2s@Zl@zT%)2y50FoG;4(vId<%n?MhoAC(!vG z_msJCb&R(*`lCj-ZVwP9b#ahip1>=-bKwHmrMl2lAr6x$IzWYLl0>1*!pA;U;X>By zV#=W2~G>oF-*bi<@_EwUbstqWJSVeipzx;dwShRrYp_u(-g6>!5#+r$-LaVaK)i;TO?IMKmdLxp zS8f=G7up5RLn2URz{EUNeB0r2#-T_rbuD!r0Tmzs#(oRDNYN4BzlcffY?TO2VQ^Y2 zaVwtv6NwUmN;Ms|a5wdJe^)CbP--2Lf8!9YUlGSl4jlj;5-e*WA=~g=sQ;$^WFn@Wq)USaI6rN9npeIR79kNDaUo9rB@o5CVW(oA@apfzxG|v zmnC2_tT&NExUMp@`#09v0`wUGe-Kk)fdIJR#}ZHwm_pznU|IA*TGhEwR}SZ9ecJH zDV6w(rLv4KJ;k&6F!`($pJGy^i&rRUlY_oWsY!9c2@s;m-mqIDHbUF1f5I2#0PfeF z%x=&AAkb99_4D8|U5C5oZNw8ab$N_qu?5jBN9f8gz|=Ab)_I21|@` z0&OJ80N?}39wR7FQpAock`R)AIWGkB)YnC-38RQQL9Dbx#&R1x?&*59GCqBm}^T zt8PRn{x9*xh?H1Qe{p~8+IUl^Igag|@! zEQ+UrL~%Gy6o-JM&9ylNa$IS-bV$?9uIDC|b(36z37Yn;^=5Xmpw@a{2B^NCTM{x! zCjOa~*G(N)0wE-rZ)!E;A#LE=-J{i)FIm;6^mbH; zo6FR)TocQY0Am#kWbNGuTR?H)`>Ll%YQUS1LVUejAe88%b+Fsacs-FjihRtMP7I}F2M>T@YZ z++r8$`Ryzxf1xJ+Q=Y$Hsw_$;Qi{Wn;yNtgZ{{%%a;7pY4KRab=de~8m$(a1KtLrb zBYK>pe}xy~Ong{GAL*nwsGfRpqTbMBV- z5Zcvyf5gCzW8BI8j7v2AFU+0fz`AVlNHT#o!M>1-BZyUnep!&1CLPr8>ceQwENvWJ z$B-lfe+|^K@QT)7Y@4R;b93=tDDdz&;g$NBVvnQ8{Fr!CDL@WsCv;Ee1?; z`DfK3sJjA}9XKAfk$wLG6T8U7tl}u9yNFKUf6L!4MXa>@lgqMsE6zSkmC}|JhhEWZ z5}!|N`epPGT@yWIeY3(=i+I<3MRz4#e$9PsshUzvPcH&ynO-0gkIZ^G zf6Kl`dSxX!!hcz!cP6e-avY>7GdF3CqA)qpb|THTPR{w0N19rt9z5|xTHK_$^NwAo zr|GD`HfQM9_3&xNx+$rI}3^ujfH5(tXr z`O=gBfjR1nV7u5LmyKEJ`IO@@Z&ng*e-olYEKAtp)Jt-ro2oABMAhC!wEE_1#vi&G zAS9*y)ATQsdA?wy`s|M@^8{jv*8AnV+WO<8ON%TnaA22OFqV&l5c1o0U^U3_pZ}{&s(}Dn8JfTtj+h;ryH6;#Cm#iRvKWiSq6^ zDibH$5=MFTo?DqeSe{`STw*V1h z0QNFrT+A>7u)HTfz`F8U{ct5_t$^(smVpK{omV9MpH zBsDR1&oQ$J(0#EOn@|MhvxtwNB2tdR*XNH68=cQT5`Y{4K$B;gK4c%tPoxiAb*=+M zRgZ0xbJ-TXBKy`0lIv_Ue@L{PozfWeWPlsHgQ-9lPvNVG3qpA%GcNUOQ8{x_nYLy? zjDiOzUWN^#d^yrw!H`T>102UNlTf==txDXUB&IO3*Ai*cmw?etHi!aAdgRrZc@=%|HOXj^yH9Qf30RH_(?%%Cux{n zZRQ|rn9w!Mf`e}f@gHuFNTd0g3oEEEi@hjPBy@5Hvg#;g4sqiSV9c$*c_%@dQ1^P! zkpbv1_ds{~&Q9Ar5fi~PC?AOR<<$@HqJUh5-1Fc=kNVlX zf#uILfpLX!gsm@wf6>yPFHG9FUzlm!Gj%w7YGz(I7uMXb{lWg!ECiw9ETU^R>H9{G zP)yI~o9}6GlFVctShix#@4x~9=8P1wSvGKq1Cp5Q0TjFmEgWP*TZAObfs1|K+af&{ z!lxt{wqcA9bjq;={#c*7_|!&b{~^g+o97}R^xX;ZM^ z57|Yf>3^czoYR$7rPvIt8xH|HS9y*YXZ&c}J)L&ReL&Glln;EM%97&>zXVC{*~R;B z1hGW-9A&!auG6QUt*(X)CM43SRua{!m0KYg%hk#X4k2}JI%TwU*GV5}%wH=7>8}Gv z>Hu)j)>ckIe<-(WkNZ$B+{!{1x3cwDtW+Jml}{aSCDwmPr>y?1bPCI!ta-erid7+X zBB$v$u3V~)D;Ix>u|S{C)DWMS)`TbbJ!2^t*yB?@$7#?ELRSjvhEws0)p{=bUL7@J zfv=(*O!xe&*t+yeE&HyJ%6C=anEHz@E&GfhU=wJVk5{L!q+U=MtB#N(kq((E! z0#peA090@Q{pKoD`wU|rR5eLq1sd~8={|f6cI@pBj;EvW5UOhuoe|!kO zQCzi^DeDC{`Mf^?rF{PgfT7Ac4303OTAO>me|Yt8k7LUF`~603o0X#k^!T!@LbB`B zP-jx$k5M*RTY=56NC_S=YVs7G31f_%OMN^QQGPPu&b0n247XD0ZFs=*bNtOwzy^S4mroA#eP?r(l=><|Dbb9HS& ze`kQ1xbTGo5>OC85GWBS1PQ@lg0Bc50-J!rSR4`$ok=>miR1PYjQ7lg<;tN|@)Gv_ z_E=thz5RV7^vymU$UH2zy|p+Bc3V?;9B?}c00=MO_G6o5NG=BD7AljsKbnWVU7@hQ z2&i#DTiA34_xz4wD2o@zjNDFHtKsb6fB44Lbp%cQmnZBBo~s(T88xepbVwE1`h=S} zr8HKuMPNk^^XSl!N=OXie4sr?f~*H7L7K&2NyrLT2qf)3CH7~M5Jsz&u9|yBX@fPl zH6*5uvgMd$u|*5tNYY_kp!$&9-^4B#ZwYe_6sb%iI4(~87dhypU@~&~jgrQ)e~PfX z3a#^)00z2UX+Dgehsdw;k^oc^Gw6ORl~|_#Ra{EMJ@*StS~`y^xbRgPA>TW9eY_6OeuoH#~Zd136z2PJM!n}Lcm>DV{`Ek^1% zN*;ceRt&k5Kd|mOkU%8Gg2sk9f4cAgUnimpPwSmdiwz?5>MiID-@9l@8ss8(RTgXZ>MCQ`{XzBrMb+GAl}l- z2r)2hR+r@Tzu-u)CPbEGBW?@HSBwOYO@){I1LCKMSR)D68VNF>5Nj*re|J_TClJ-* zSV=`&`Y44ASLpioQ$FC%r47|0x#R@^2xly?;180l)GgOdokoe(qGZ^dSPM4j=p*hb zCNu?%P9!O$6G=Tzu7xKgAEwBJ?(wQu3nM;26KcM$73FQA4$?Kuh)ki5B^@dK$3pG9 z>*`-+A$6@eP?gore3Qus|MBq*;>S_RBROP+P{_Kjao73+%Hfm->r}R^jSN3^oN~7O;b7Y zaH;hN7vvNGP#{%sWOZ<4bYXIUy}0lh0}@Ce2#g_kh(M1Z2*Dx2e+Y>nAPOSSoUG|? zbFHePWjEl0TP-OA$n#2LE`+?S6-zGB)>~B!M!BloEpqOS1#hwA_$fHPxTWhIGj4&) zOP%z*+B#vEk+k_c11!GqsxrrrOn{XtF`IuExbWBNOY#DzoWM6nVHEn;Kl@hC@4<&4 zxm42pS(g}c=jBS(f7iHb33X;ly}1S5dLfC-OK0%8X8#tb&jU-T2{)wu`xFvt-ANI& zNRtqC&5oKx@DxE`;0w|Cw@|I5t;*?HMw!zFz4J!H4B_+opNS9 zGPCdn_M3|*d{1Y$s8RR>Ff~1SwJis(k_=k!K<~;TGQ0EMOoIB=n6LkCx$`}ih{dnw`iwEq zz~aA)3WmnOe<5iMluKE@Y788>002mBPoE6UAwND%*55zY0)?oVq9<+gEk$DkvbM;k z$8x2z-7MSmlF8Cvu{PVmWM*+eEKt9NDGGQ)1dbhwpe=fWCgQ*u9(7u@eE`hFRVe=ZhvKZGw;skUy>c~XvzQt%7V z#btcO;RHX!Dv&4Y3cSsEc`^fe%Ev&}h^h#V%N964YQHIwuj~XLwQwSq_5j9^yBMnh zlokO}jOF-Q6rCmxBY(Fsa(zR_`h3y}{)lo=hf`FQ{;a7%iU?0g9@DMtX`K zvd+{3f5^-Q001y>0FMAND?Y+a>y`f*1nL4qufI}#m9Bxn^tlF9htRR>c~W?49+xJd ztw9~QPX25O-Tq@NJfk-~OcpvCBbpS2Dnf3}g}5IsGgh;uNZPMwdo2W~=XcgDc~ z{8gm7OxiP5XQn27!EiZs;lXx!`74L7c*Wll2F~RH?1kI!@bRq-0{}Q6b#-!8ZebI! zkh=lHtP-Xk3ZQg-nxEL}6sl?>QM)9-;iv{Ffea$3z`)mkKL$(TLb0sWz;XnOB}MBEX&d=?ume8i>^-Nx>Tm0ztCoADZV&hYTn_*!b#iWWWq^gK@EHRk z2*C)2frbbO5g3G&_Yy!5*h6qaz!2yOJ|sv8YJ@>(kZ?>9)o|fwL8dU8cCB#Yy(IEH zDPx{6L0sO^P`o#ycsZBw&cC&2)Y!tTe}3f1!7;ewqR|RX5J&60$8?@#FCY2(qEX@( zO>c}i-DJ^(9;B%638TpfVzfyM)$;$nM&e9Ierix@z^ahkM9_X46%>ZZr%4x;MLz_5qIO4Uydxe`|FB zTR{kj&e+*bcl|n%kkVQW|1}gX$nN1IHs_0s(6Tvi-MR+m^|z1^k5pAfXiRGMFnC6B*1EfBEEKF8K~1%!Sqd zU~W0r2y;oKHeqhlM-%2!$rO^kh%HZ}52z@7Y&yLG6nibTLXqxaqUN=mF3AQ2gv6!H z%m_fiR7B@&9f3AlYgldtjFuofDZ@~>+3gR7+lj>@&TM;Tc;(WeHO5sHmmOr1Lmvml znq>rofB_%`@=A!Fe@8^eaH+IUG(I)hCT94IWlx2B8o;+GkpHwn38e5l6DY?PVE|$dMp8e}Pat=kw%vF;?IB5guCC z3pAsV4?`t9k_8$s#?65QE8}Jm(IF@TIl)FmkcKd0 zMW2_487E%W=yBPQhp0O8FeF1CZ7}^4FD)5oAnr9;u&f?s>ZAql5#94{fBhd0zA1Q1r#mTvP({vg@&6n-l0a&z zB&wniOuz4N@B#Jc{%Ti37s#tqX#u9{)Nvc)eqB*HDP*M^iI!zZMD@$(L6X34->SH3 zShKkI5OLb1r~KA#ch0{8Y!r@1N3COsuLrwc`YTUL(IEOW< ze}mq*<{=V5h#0hOaU%@^GRJ)?h$Lq&a$CH>q+HP>0*Cm7gta8xKNFd`j5tq;AkU*Z zRv5!yQaS2^rf!g4k)45R36m?k6?U{u+I*1wd9rT$YqhN;RDiVakMoJxJ_f*m_pCgs zXNgze_Mo8ZS-REs8Bv>5`usgpv!4_lf1i%rO5)d~Oz5yl_qVOVR*LN+;wNNbglZE}f8Nv~_DT*Xj&feat&h@NK- z{J#)s0C50x0GLZR6?r0~ce3_Nupd9bsX?a-0??6i^;nB!wEa~Cy`3y~W|s5Q+3i7PKcj`|`6hea2LjAcmr zP6hHQ?1KXc=AkqR+Nn?+e;XZ&U0bwpQoc>`o{B7E2mut0>~1DJ0$PwaVWOzZsFG6b z!#GQVfs3&(04h|2bgQJB1N~&vyQ~t}YeFukhk{5Xk4{NLP@23?OYQl$t%NiRX_*OS zui*6Mm*+~a?Na=7OA*^boKM-aw7TVPgDxdYk)z0Y(UBi@u6`Sde+a1Ldy|Z|iJ$>9 z01%MCz`;WVBat*9i^x+D3g-g^puq;c`5C8xH9)JJ> z0bnvHASe`Kb}9q%;MN4ePUR3r7`7Yh6|XFw|TS6jZWEyNBu6*^l3!D;s=zdQCAVE zeI{H~uvW2hJ^%=itPqvCwP88tl9c#el5NlTt{nGWMPU_JxiM@hfh5;GmRb(%fO?o$ zjEHil=$#EjZ9zBv3fmOJ;3towWO|Bf)vG|zk$6@)IFs(=f600c73lL$<#?^cen3JA zvRegO8T2hcH0XaFBKlSLP1Ie77)Uv4f=fc(_otWZXeS>f1jw|BDlE8!3uyoz z+X%{y0CQHG53dmEkS7n}){Eybm@yX1HQ3r~PnQecJ*CQ-5&=2%TjW(R?C(UvLzV11 z9lc?iUOGL(e_>J(-FI^io3S2#?I@-#MDx$0vw$?zK!Mdm5C-$ip6oOad?gRWzh);h z$mD(z8SQhW1j5}|*IkWp$>d+9Y@fyr|IwAny%?E53|w@0KP5U|g=Rpd+X<{(=HJSi z4d)23e0y{Lq!l!CDwWq4U?zHR*HPfXq{oa;#^!oSf3cGyZ~PeG@Q#4|&hHg&P-|`0 z^!bXG#vSx*`*vXD|1^>ZYd%U*hkqv5Qcn+YUl-T(7aJQtvr=Ev>yb8qqf8l~w4t_F zsmCG4G9^gqswEjRe+!&&Sh)Ijl}lNLs9lq~_&R!RN?iTou3|93Ey^;ccp=n7wwgfx z!4Hs~e~W5$ry5YKGV(eg>p~%LnsFnFB z>Vs+yq0FI^_;g>A44M2_w@)e z3D1_Rv3Xl-ODhSE({Gp+%N$RV2QOMkX&n--l>ICHY0h@1<#T*(6Y10)fq6n}!YPT) zf0lR`I(7hhHzp5dKK@BdyjoW>wL(|ojh@^32EiULqGi-%0Oq&;@WT0->geCKo+!X4 z@gAF>nBgY~SEk7G&6{PLpCTwf8}!O$356b|58^!k_8r7fX{gP8GgK=#Mdw+c@!Fj#L9as)BfZoa)`}n*TWJBud63>1`P{%Loyb4mA)H7+L#Vsyc87e-7P+ zGO2+Fd&nS(lE)ke4Me4-0Ep$m82~Vgkp8ez!|U>#^G=MQf!c{=(F^#PYjXu@$Yvvv zLe${4;9%!G52LK^^2gz%6`m}m;uv6#1|dZ6D%04f#zTbyqEk6GaB*{CEp!?XBDP4F zHp(ZV<@ufZs_-bQgnq9bHSpFje~+*-V@R5coHxL)-~JA6LQtp8(}ZxW>q*$Vkmv^^ zL3u3XC>NXS5uFdD;LrBmtAI`ufAoy$Wxi~&ZoR z%-*ilY-K9B3tC>TWDa^7{f|mbqM54;T2{hHdQcmqkUwe59im74kanGGrT5p!bFpRdTutEjp6V7s%>mX-XLng|^UY-_z3Hg^&na9+= zN2w3{hWFDh z#Tk2sy=vL&sb{!XQ6YdVFFJkpwr^?3`D5rAPB56INy|;W;GW(#*d4e{>k`E(Z>0gD z4hi-7k_?Ll^#3xdf}Mmhr%Bb-mdb|e8B|z#*Bc7>jL{EaW2Pn&e@1Lg;GuHllZtfr zzYu7xB%?4eY|!HpNfrz+2(p@cT^_HiR93s;arGe!U!oY8B|JW9SZ$#zGESbu;Bwyh-Tb_5dI{z2udxeMN^7lpD^Sx|j&G^a{M+uf96;^`ZCAW0uJftqr+l8{?}>V3{jA3x)viGU-SGhLY`?=P?kjgL+=$F8IK90Dt##!@N*0;*@n3pv^7b;7J)6bEb z*R%Sjf06O4cmK2Uqo}alXnn{LvxB&ik=5Noc!Vzy(vrMw(=&!-f~&yog{|3H`xPkV zhWAPBr__p?r1L8L*k7UWr2veR5rw#ScmR7kB2)vj=+~Gb@qU?F#L~%Edj1!1y(uZ> zj&ZeKO4qgw@(~^0L8gn#ucTGCe}TPD)K8M2)4jL~6+wHIHy?#hRr=}V zbXYQWX=|ZSV+kW?iyrl9Od&%HaJ~X5RSBLK+4x48Cw*xlDDrMWU1@(lC|KWV9skdz z3TgnOF&6oY{%29WJ>_Q)wUh`LPIXWU$|Vs6y=FO2Lzt6MylU(-xITds#&dUml^j5X ze}*jj8ThM{+4K@QnxGUC?fmc-V?1%>q^ve}%G8|ji7#+f%SK|QSm=n^mHwP67-|`? z$9c#?c}?jtN%9iM15$GnN@&SQyiGcqa8`Cle`L9o=wRTA^uVg0P#H_7p9vjW)JOnr zfrk+{|MWXb$0{AmX^*ct2POb`cO`i+f4V!KmgBZ;xfXtLe&|qBeV3@ z&8=Jrv1~Wtb?^h-z^ip~z4;feXY&a1o(Z3>Ta{g#5yy-bCMpKNu!E4oPKmjpf12p> zp}6&V!>#Sea8|?$zO_P|e2jDTd7$7$)Aa=W(Xo3Ma^uzgUePFyhXg zAK|j=L<&9duEUAg7sucx*J>^@e;yA+>3pZmR(}&042_EBSw@Cb44i}2N_U4rQG6uu zmbU+hsf_gNqdXJSr5*E{O*e6fnY)xY^FVj}gIPm0XdoNJ37VKytcA8s^B1}x^$2c2kWd{_4**>dssTQoNo=v;bNf8HD*&Y$O= zvDI+Ye{A1W0B?4x@-A7y2NFZ>iC^dq01^E>D^b*nXL4NWZNxRRJc-*)*>`U+Ts$BK zIP^)I|FF$Ej;_$8FllNhOCzJEKme`}UbOcl3rp?L?XL^SmT`T5ohxfvBEF5Hwca~> z3YzTHq-=*+g0@J)g9=yke_%tyu>FA$e&um86^Np5b()`F z73?}YVKm!>(FLaXmu`hV0;SM2xCA;5shKn$|6%NdWkjzMghgrEe=Xje)^FVj4M?an z>K+x~{%mG!`|2~y2VLsv(x-f)MRRsiYr$(pc78jt-vQvqQFX zY3Db`CpGjgZ(z>=e;F&+Nk}P()8{HfBJrJ$bGCQ7vUkSb$)L@-4$O*ho7GCt6=Yfp z0FhD<;Tnf?WUusrARUo~XUx`#fO3=jZiv!p3IbH{g;}8*bUEkB@Y(vI3~x69^4T20 z7lvrIoTS4GHx)N9!Tdf2!38|-QVT0qw)}9T8=O=F7eTIne=ju4G^ohg0oe`MQM2<% zL@$m2whx^>FJm2>2$E|APw;pAFCA~j$|w#h1fYOg6l8ps5jKcao1Pt>jL`<&5EV{3 z=#J*Fp%D59Tg}w9xe_TyX*N6kM0LliYw<^ns&QHbl z@1-F;&*8@AGtR7#`a*J|P!IME62IJ>Gpsr${pDfp&9{!OTTW3gHkrmlJ?;4)>KFcDnV#GNO7Rxpb zQ8AB+e`hmsYJm=D=K$f7u4mrv`t6l%R1E!arvCmEF7u@XI{3 zWWAVTTF~cOBWo-EVWf!2_=*-fT*HW+plR;?2L<>!j>3eYU|TGwfaGh9-y(lR=V0)i z+*-r0SW8&)=+-`!8mACfgGt>~%J z@}P|^Re;=n~|2mB`1wK=~9I{5bjxqGYi-}ops~wA2m?=JcP4+zY(Qm6=2I?Nj@q3u6kYH*=cPK^9~=#PS`g;VYLT^ z15oB1J{Zq>2}MAt`5Ciy+1%nVt!uy@YA-r#QH^?p0ZwfkTria%S`ebWyYO>%g9eH= z5!S-D8iJP?c$UxpP$Y%c+OyD!e=LLeGRVAn)9}hSDq-XTF`O;bT56&Yvz`Tk5G3f= zvipTEO=Xo-&oY8p2Y>f6|fBxoi&*l=-Bn zRz?O@hcfB`DN-IKZ9fh>t9@gE+eRtwy)u%ce$7t5;rHHObQ;0A{LeYbc;s&m-Z=w; zH_ep;(?Wdu9_V&$>x(x7hN)&B-I%e8R6h`j#)B-m@u8!6%SLYoCK2|LZ*h_=XQ3(- zBJu;tc3lMs1r`uxe=2B3kIhX!yK>CMOGOc++?d<40m^C0-Y0WqmDSP0E+ zj4Mx9G5W$4d1Y$5H8yAignq)QGATacwEqghh{c*SfdZdge{-e%&90AcMbsm0L~tb} z*QPJo*)}?cNxuK0!+~0z15a-)vAHpwvP-Yz`gl+5TOqYa^9RisS%57)It5O}gSfC( z3}z@oJrwqQOZ!RGul0^u#Ro{}|AnExaj@Qhl4PDs6rEsolZ3z|+OV^fx3x`-NIi)K zBQSQp5xT=If0YX6;I)kJt~s-z42DmDC)A2WOYSj;!YQr;zZ>nQR|U#%v`?}gQWuW) zs`dNn*R1|`4TY1Vec877G)McaQxYNx9qpkSskMnu0Y_fX>u9GI=vLd&o|FpS7QLe# z^>1-Do65fkEeMuJ`}DtqN{@CUj^TBcJ=&+!@0%-ye;yUx-EGcCd&o`wxsP^Ed*_i; zKiWe9P$~atr}%8%$pO+Hf~FP~NIU0?L%50{?a?J22ZOXTb)5Io2WgKEz^NvrohTrA z8MKi0D3~6jA?-|L=h1G5v`5YKq!4K*m54J9Nu>RVZ1QAvQrhW6o34n2cqxy3 z5qve3e;^0(Yr47?B1em!D?~s=*ZB*j=Sm-xR4t1e($LsGfuyvnrK<>JgTc@laVH2o z*WPOcr86``!u+8tF8WCs`mIpT9EPor8N#W}n?9P8Mj84GE`dNdZcJN7%PXU9P=SC^ z4JAmwJkE)S(VQt#(FTdvu%o*SN}JBTFJ12)f4`0ISbLxc9RtN0stX5MQJ zZLmF10zTLg{z)*aD+(7Fe|0^r@ArqkqO6C~0U$2rsNPc7wVQ-OuWdIbAubt-d9Y<< z#<3pb5V(zv^+5S3rnw#mY9VX!91>>AYLNXp>#RjEy6QoXh#dPP2R%E3P>qT#t;Z|L zf2gz-7kPj|Wl{XV^?Idd(0OpO-&GjSF=tINGiF}XBPx;5U!4BhB|^}P)7x^*Ve1k| zsZXIaAppch4_}is)6PpZDFD)USX@rS`+;_@ zYo`Y?uIpCZErVVwmcAO7mh<<*i}GyIe?laQ`g+-G8N%h+@1iW=s`}f>v~O)NjJbV@jIWx8Fmy9HFqt-cAud{Q|LaI;mY}g{a(3ml7JnC6Lu2 zAZs|I@&=iCaDFdN-H5DJar?U@MK^M(&SGb^nxCG7sHShbB5&K)n%yy8H+OKjOun%I zjDNR1O(WMvT*+n7WuR02b=Ww=Hd#}k)9Bv>It}^BwJ5nPh$A9#>*(taR_= z$$xm>Rk2$v2b`j=9Iy#PFu?K=w=mO6`+rz_H{~dWN(Zt?sK|#LCJ#9?7%wwtpf%*z z=vrk+-=^iQPMDHH48}JKhoXyE@Rg-H(rUpv{1B9`4$dDHUEF|(4K9UXi_+3$5HQU|xjRinLiA+miXuRFJZ?f91gzFK1){j1N%3KNJ>83Xzz;y%0R?xHDd_pqS`)En zl!gra%ARxJj1Ict44YiMa95lQt?^Z_zA3hCK;s)S;4oALlMtTT>Pt1GXnzy7A@}0d z0YVf?1=CDwQgj@FKo?`oVoa80l~cK_L}IbP9hmLv{N%F?Mmnh7E;Xz}*yy*G*?jKt zhp;9&lDJ5(G)-UdLb8VlI!Ug>()M{wz_<6{OjpI-$!kDv?M^B;-u7t}KD znqOL@OavG?BNyDc8KX?z-hcH5Y@w-*uQSt@X5lcmRxxBVYGVyR*iA$!%GjvJiyK)7 zoU$mxZ)YxC3#pbj+n|Y++;$h5o*f|ubU^3vLYU^+p()vQRKNrZV&ucJO;J!|*CZbt z`LdBJ+fz)QTfaHJIPAZul}Ad?sh$*&MnA;<9C_9-ocH^oIJE06%b1sJDm2)Qmlpv%?p3NTVI8hP3dQLfhu>NXh?ojFCJ(ewR|Q)ZplA zXh1uldq?DO<#J-GWzNW1Yuun3uB_gn;mX1e+(|z=gSIhTxj)D}hel;+IMh$@9>$nU zH_r)IPVr%IM~Q4}zJKQHJv<3Rge$jLWZ1I{+tV=TMuRJd*lWLfVQ^(;n@y>TSLKta zBIlq{r5puU26K2WNuQ!PgxKGd0=lSgnoX&}0*wPA2@i~J%8my0xJ8!&c@PW?JRr+wxfEx6 zZI*uFThigiQ4PXGGI5Bo<4IAoU?;;&LilnqcY_j>>wj|J&2>KZ(ox)bigKoR=1_Vy zcg*Az)5y4s8@L0Gl-mNZ1 z_P>c8OMhjv;!BK>Q9(I~j&h_cV&n)`4kFb_Bfl*%{Ap$V(Zj;UwG^+l^cwW23V=Wc z9FWS==!uI98=IfQFQ?r0jch~|H^u%oxywFt$I}THL`;fSV@taFV4}nTSFL%pQt zF+s;pzZ;Op=bs@W#KE3N7#C>Z4w4R3ot8|&dwjkFiyyWaKLr+eFOd3^h}Z^1Vj1w% z`+v9S%dH`ksclKmYG#9I!j^N}`U>Occfm10v%%2*J(dVqGu00H4cA&IsvUk`QqcARmA4+$s+b1S}kJJZNWusjrbO^i=?`U zef-;t*m;|Q5t|w|2!GD{NnHkoQtC=K>wi@fN$z$$?SeLoF=BImy-=V>Y9Dnu{7o&2 zo%=2ScE{cLe^va)@fzQVt?x>~MrYJsJbW^`66fM+|6?ab`4l}KMjoU~zCZwZ!ek3S zqfiS*AQ{x|LW=?wl?xK)JODc+djw6ZSO{t~CucuWjRed}VpT#su@;eW!NAuAdVd=C zfM{6+I5mF-uNdMXDH@=#$MgfZw-CKV0h)9FP;Q51bdja<=zH$1^&Wmp(GH?sym|x8 zjoc@`INN>H;}MXCa}_M<`0HW=X!XK(1^^!IfFbv0NWTr?tSdD6N&Hj$**!~UD+{t+ zCyZ|osDunZN+Yn@JB!_73JshIqkqb9gv+%vA>{BR3lR?Wr=TEi=F$%(*ucVrnWsq8 zo!g@#@8mb`EMYAcw|!Mgi9X-_2_9N zJpF_7T`!ZwL@<4GT9Sh{NrR9pFHp>yMrBxsGVHIsG95gPE%%Vvgv+uPrhnN1vNzGE zkH}Ms1UOAQv~R*jDb;hJ87M;bLzj{fTJ~XT?H%`4jfmZ7u)l6q;*A=*E^H=0TadCnVf{oa00&=bW_ zrhrudbe9oV1wf@!mxe?qCx4Qv0-*JX8QrJc{gw9KhCW?ofC8Gb>S1LTXg~@iw-{Fq zn;miTI?~HP5bk`*!+jP$xPoT-DsvqxeN%<81Fnk=h>%X$`(i|8?|}CXHrl7`U9-nn7|ogNrtB@5gDHEtn9wcj%w;yg3%Xc9dz z?t6UN)BSWdYtPCUk<}ogKYmHjy89JgcdTmZGc8G!M_KRr&UdoGG@AUQL(~LGha_Qw zUBF*dAz8O6iaOqeib$GT-!(x47@z`skpWI5u|L$eB7bf=D{MaSV&NO-5!L%t!FcTL z(~plqV3S6)LGqizZAxPlJVY&g*AE?-em&}73oH1sCvyG>*k`Cr25m+^E`mY<_bb&9kOPf=h-Kz^uvFo62+AUv~Xk;!9k6~!}L^VKlg*|{;-UpQCTGTZlEmL`w?C>H?IBlwSedT^x{697uW*MQ96pJC)NdW#; z6#~%gDXyX5r4Q%P@EqHQ^aAIJpxCG|6uW96g@2nCD_DVaIK*$x3qc{(;hg(aU_izA zaQ5?M3!ifjj!MKLZXycOQ6xZQ`y0gF!f3uFRTpeA7at15_}rYvhpu`MDO(XSj_zRh zvNe7nt#8OBE|*N~Ylr~eyw8R0)bv(8;y{QDD6lSBQck#-zuX9z#i4ll~4rld& z%YR*<8?!+xa@CD_CbI;rW_23n?EB(Fpya7}wBsi3<)GGjYm|Ix<>uir01${U2e{UZY+ndO@63-Rky*My<&Lq%sST!ZnJ6tyFv zMMnU5+|4);Dp0kczSypC@<*p3ATM<0+%Ol*aYjy0WReN-^bc1zk*2NfDp|1NZkjHc6mGkrxP^m-ichJK0m0I? zmK0QS^kS2=8O;+QFILTU1}lLY10NetN2bkrXYM!>p+JZYnkm_-=J(_9k%7(Dz<*1t z4B7U2CvYXOkVMFcksM^FjBq0osKU08b`j4pjRl@0qp8F~{{An9Q{)3^-^aNfzg6-Y z?>}4P41h_06itMW)uvt-{Pi+e*%nk{I0bCD2jzD2MH9ZkpTRd-=-zXlAyk-=iFulu zt{JT@8SmJebXoO_ym2sCoH2spO@EIL;(L<^klc24tS7_1850vfI8JTrMQ{CJwh|(F zmG5T3#n;q&Inml!&o>(_XRyoFzZ-Br@X-AfpWf`7(LoSW7&Nl7*oQB-8RPpQ5U)i+ zOLm&-K|RuA-q9HVrI-NF)+?r%yYSVNKR($qi54<6L8Q&o??Y3V*f>m9}ov z5#aF}deL|tk&BXq~p*O>&=3} zs<0RQSW7b{JhYH77x6&4fTHE*F3Ro?gh5LWV{g?tE-e=c7UFoQNQ#pI5;Ny~MjkFV60FvIomWp$wvvxUt3D$oNoIU(i#Jk1dvZ&A3V%_gGGsS`bUOV+ z0NpGmt|3D1$YDM1%cIzn&%|NaubCMA-_g~{4e%!LaY5B#AmUD3;F1%F_Tk|YTS zCjgDcU%~dQZXnVt`hP98N@L+O7_ZKOZrkkk!s1s+2y3Tw@5*JY?a?n5_LXO_J#2_J z2+Cp;BHD`J*7HjkiF~tu_pvQBAIr#|v;+xr!QH;q-}eXpz*?sbKByfXA!}Xf_X90H z?~U;W+asS5v%RvqTJi8ZQf{!(moqN}quFcl2#REw&+;;oz<(wD5{4CsDGY=`|8#wR zZiEZ!(2{VqgMbm{l({NHy}aAxD@S;U zZfEc0Ev;)pb=cLo17+bN;&bxJ!wBSuZ4d)U$OAddxI4uYXfR3BQ+*_*m%zCg*U#>| z9FB%UL4Q1lYIYfXpxn9S6?%mF)&bIGu=0qp^-*rwU%P@j6jejY!FU}rbhL8V>!^?# z7AZb6rtLDu%A6BNWU$72qMAH$;tUOe4BS;^=;j9e*b#w?2ZCBv8{99VM#LIvYkC_2#XXG~*rQo^XK+h^otzLYG$8};<6~98BQV$?q99pkJx_%T%}C4(NohWRX(QNwpDn`Tuv##nU?X~d*fAj zC!h*kH!HlgutZjPE6;;bTj6~f(MjqG@2twX!dr?95}OP&MG-QI+BYFl{t9o#(^q)2 zVTeWa;7y-9{j8u;<}3~;#m4mI3E8T{T7RgvVdzu;;Oz{-Bq1QY#kD0BrwoKQWnqS` zAiT*2viAJ^{L2sV&{+}y0)3|EP#vF(}OEW%+xdU_z)oT zry@)b+U>zBK9GG{?59OH%{OY*b^!0M=rt?O2+lq#P+JrewS*2gwyn%@O@9K{%Cc@J zf?z#ngxg;Ha*}5Y{{g6rh~x$qHxGJ?2rm63GR;%P9P(z4dBvX-_Pg3|X(y^E-{D~7 z4GC6{{lks?dd1R{N>niR>!1Ug;I;V!k1JRMtpL4C^mN?0rHsJ7|87?Ma33m1aGBLx zU*;cBB%T%!1^5w=tuXEd1%D$H2{D~DTew)SOM#66Pl2kzc!fY5dJgWm0Ssja3h?Iz zb@Zxnx_P}5CB-KkYyuc4Sz7u`58pvx3NbJ_!~gwH(#n33CD@6K~!1H%|`r{CRrTpx31!3Jc)lATgSUWI(xpydSf) zt6eQqAOxPy23hFg|G()4F5|%D=0wt$k!S;2X<@-5(PcOPYZ0OTfWk92ma9FY`+sX26a!9T|B!;%?R)yf zLUbK%qXV6^Cy~Q>bAZfhFyq4yGoS+Kub?nkmQax2sA&khXs*et;sTY$``iMxb)W(< zKH9ZBN*IXVyG0(LOt^rQD;no&f!@rhsw~hDS#Te42P99I31gy19@9X7*_Bx_v5RpB zq1Y@3Dzwd%_J#M6X{pRHlWGu{x3>_$lQ@w_fWnf$=JBP67GOPYuRh%DP%l z$J(sX8uPH*noWY*^WNTKTL8D3gtsM;WyiR(SBoBMtyJ0ar4bF6$e0%K&i&1JG{_$Y zDj|Xa)iI11Zuv+*mhheg+z0+!4}l*iBn3|NQWFRzD1QT-;JlMQcuk$|mc98>`iEkXSF%9l$tpTG0ROAU(I7Ef-{Myv^o z|8Lb0M1Ov41@)fcH3tf_Xx?83Z!DZksJFn-~2uXmO90B1Q zGUs}W{m!wW#Rd8WwoF2e^e9oPub3qZoS5zPlz)@9`x3f1Xd$zQ&f6IP=@0+{e9K;b zFlXm6pLHjOh8*iu1YE7inBO49>crS80|!nL8;C;Ul%x%!c1&BkhBGLY@x^@A`)Y^N zQkdtG81$G?h#*OdS z-G5wSr-4Jh)WZ>w4z$5P*5lOpde()9)&$gMk=$<1m7Fw; zU6asik?lNkUd;l!Y#h1E`-T;(OXGsBm5i<0W8dg_@ci@Z|cAGO0UR zVUOqkC4W<& zhlihQl?pt&MlQwf(6r}Jh-CXL#8i^`9|jD<9Q*%LneG?Q=cAM9H8TZN*7R?t)sJ?e z|M2P766e-v{clrkZyAHlEl0Qu#i9SkHQ8(u#Lq!OE2&pSO2`1}wBUiTung>vC#eQ| zxX+~L4|J0~z@=X3j~+12vnG|0Hh6A_Zke z*C7>iCC9BFkN*L^Y8k+<;CutSI{}_A^o;nJDfPHn>nht5u=?~8dy@n5=$l#tX8h~? zJtMY6m`S41xX%@!YDp@SuqE`t4*((A=DD}Lro-%IiTw~&#;rY2?-L9t8h>-0>L((G z2f$6PD?sC&W1$Oj$o3$+VBK4!s&~w6EZ!o)p}}|X`IC21be<{h{81DTND6q7nW8kv zI=$52#vsqRcj0cp>3T}!QfA=cR{bm~HEQ<}rAjfBJt|xzPc zdZ{fw`=W3xuy48;z^0*@%-&B3v+`Am*t{*62zX#DtQ$%4{D3@2YS82?B5%ngii-IwSc>391ad@f5Ifgvww*jH3Iw&GMAj5 zmKnEtO%9J{B8YBi+$RbV?_c2j3Aw1zoInSv zJ6?t{B`6&H*JJLHV=yM1jpILshP@T4hAW|m$t%wOR-keAljDXJcE(0>9^MKytj7Z$ zFxFtyNt}F2I8Vp3a><4gs2{v z(s2Q$j)H_V%fjo06Rp;FG<^s@fo72Kn;TWzZ~-mB6N)yV$cQg$*yIK|nBE=IQlZ%Z zobq_g{)a5vht7c39L0REl(;8CszvG(p}99&Wi}aN4yuMi8h`NgV;fipFgn=SNCwt6 zJ=hMxgcnW~`pAA}M18Db;xx)DgguHlZq1{(mLF>5;Ds<%Ub>=>;Ikl0W_6iCz?x`FV6}qe{&tM>winDMH|eZZ7}G zUrD%Egbck7_m5F7F~X$|sV8K(!K@8OnBK=dxW5CvkPV&_rhx((xspR<$!G?kQ}5p7 z-zUwZXhcr=sYhGX%tY|Hkgzp`(mya&bZJD(J00v+(0}3i1!q!gs%St^y4eU}ON`zpx48@C3k zSNz2)k$?T_SXJ`I<7n2`*+253!vS#n6#-~nuBGzS6QgFG-JOT&ek3V~X;~vbY*l2g z+J5L#3-Q@EbfOQwcGL1lKDFXJax1m7ytc_ERRM-umg-@i;PZGmA}M3?myF;Hm98y} z5L{|711V)PJY?<0sqKvd=+7w$!S6g^?OZ$F!hb*PT><^R0JfuNBmk4!Pznw>G1n8wV47 z^ncaNI{XLa0y?kj3LOwm4HFMV2c_sOMjao34UQH}FR>6#67PD+FY6o&(-qYcwdl0upT zLm&`HfB@kE@^~y93Ppp1n#$@96Tra&1%gvBXaofTLO>7@1Oh=v5ClOG0)#{m1VK;) zf?yT`pH6G#CCbP4026mqiGHWC&wmf0$sZ2(oU#g88$aLAXhI)ZA6Akv*~-EqXDAVa zp3gjh!D*?^9!z=OI8R_s1wEOe3E7BW$c5#+r=_v49hOJryiCWfN5Gi~)V7ojjrP>d zjt8}@hL$DGY={;C8!8@PeGP1dvgnF;wugf+*)Z*3{f;k3690#k5rHOOzfV!`SsCIE;+Y*3Gr{ zjTpoutSZGupCv-v{F^TV455KizMKTBpS{Q*kEz^ze+W|_`nr~b_wStDFKT(*X11c`eFXes7F@TrlX3hVBx(r!Je#~CaEJ#H5@^~n@vA(9uCx4aPoLQCR8^GW@ ze*lsCu0t9wBM8X+*K;X@Yyh(rvf_b8g}F=fP@Ol0!xQB?UK3|S&}G#TO4U&jIdBJ| z3C-()x)I{Va{!oN!KRIDLHPeV*jR_Wus%MYRfLczeHg<{F2iaKWt*zKJQD(ZlKP6= z-YtOe=WOq!Etnx@h<^@e4I>JS#TR*!syuyKxQsU131uFwXTH`3TX%Uzg|-Cw>FWB@ zU7_o;uSCcvis#`*!bCVD`^ul1l1P=iCLQbu-Q9BzL&2^GCW!4g^p7x7*&4X(`9Oa3 zv27x{B%LIIlt-FS^08GtF<``*F!nDcA3w>VjgZ7U+YpwcD}U)wghlaf=N%nAT_35P zrUs~#>-^`n$@f2qpbzOmYd0%7lkE|Bb&118}(p59H$O?c%JOI!2m5I zPHkp(aP3gZh<`T*q>tE*=FBXza7#PH0~i1LoSf@rUobLbIkvxAYz%ra)I^qc7w|fC z89-WZ)X*HQ@}NNz|KV+xOEJs{2(`e$q2oF=JnaMRA_rf9D?JPek0w{KjDhYBxrjk; z8lYLQ#MqYx+)ErE86V~hGJgOi6v%`ic!a@PWl68}mVacCZb9b#rx1g3t_4L@iZ3BM zv0-5hXkN{td-!58sE|DD!jRC6?Zv092~b<#o*|r3_zFZ zf3l@!KD|@|=8qo4WUnHlW$zkEFf1ehC7rQMy@;}k`R9Sts-^pN%X3`<>wRrJcXcbZ zdXE%tB7b^m=&dlYobG7@HEsz=(A962f1E zy5OnaLx3U|Z-nby2I-X8^#DMY?)A_YDNA^V<$o)a0O^-(M(}%Q_4t**DxC~cpvGyI zJ1cVFb!QT5h^$pz%#;m?&ds5|yD^MeXD<$$JB!x6nw-Z`MAijj_>9(_RM;mv=Y~A? z{t{~i=`sg6cfB-LIwvdP#yK|&NOn-GzW}uc?&^d6@b)aLFTUAY5D+2LA8JdWrv!msi_LsXw<>F|4Vqzy6gkDwctIL z4)AOCnQO0aedcU}I!ndWiTzQUM!{SL8mGBym6S6&Vjo32$fC^P8tA;7Tym@PNIvqs z1IC}XrhvvpU~fv=Z`Ic%KAd?W_rGs{<_ZY63-Q+rNs6 zcb=HRv^+aq-J{jiz9G<=vGSxLS3{{8JCNyEbB%-Z!;y;BIv00ndl=BhFA64l!+^UP zArqt$qEL4N^P7W_E7!cLz{#dIz%6|B6;9X}>)>z^)C<_Az4!h!D4l%kmm2*YWqP8GX>fN%f*Xo>w!0M~mD5 zQ);6`zpOy1m~Fz$TnTL_ChK2q)dUw~!Dj&KhJGil3dcb1a`ljo>aBJ;WUYu1Az&c{ zqG(n&fWwf1@7lIqX!jWLi(HL8tA8>2gE8`|s4_JEmhJR@^-yfi0Hy|WG*;p8wixxz z@diN?-%FdmOT~ao;Zz7F+30li&A?~+wmbb02z18JY0>yv%Uzuhc1?B&pBot%%AMZI z66v19*Hds9kfbe9sA#6?wGIRBba;nfuF%7G4MQcDE5yv?p;aKHbRTrQuzzQ6a&N*$ zkifWlkm0ZN1v9|iYwDfaDo5e}1wZHB#{<{z%gu@=(H`kaAzlgNC~FfSKn}4(DNFxd zv8bi2IpYe~-1CW-J+aSL>`9q-`4F)g-I~|S^ncoqSHU8GlD`r(&;r>vsyEG?+(JUQS@U4Aju+o5?-VP-3XB@xq6Y zNYLiR8eS09f;LBV@ak!J8F;WfTL#B$?jLE)=4?;dN2%1^hk7C>CP`$qq}JxX%_GAg zbPoGvdbtDd+ZIx-IO@2+6SqKsPm#dy;r8f!eSW&P78R};fIik%seiT!O-m9ocG0-b zsk=(G9gX!4%WW_H-*fd!VT{p&^H**qoLUV#f0YYP!KNVi(ARlCFLk2o>)b>FC`+D$ zc6W1Z@$4Y+Ndi+&E?1x=G;u!(+e7>@Eomybj37SPAv#A(16)ttpjngpE*v1X$`}la zGBsZS76A}PQioFsF@MWUybB(+F_puY}N0YEZVM;o=f-Z*FO^w_8@hQ{9 zgoyJV1C{3{_71(lJt2tj#cTCeoY`Ao>k3?y$THSWh8G|2x__I*t-h3U=U#luT-dBi z<<3zBwo+qo?zyXYHzsTY8Jr`Q^Tc9xUbjZ%o!QrWtj_TtCts`*Gr*a~&NIjl0uQW( z-$H<{?G+5Bn2bsQo|L~*c+PilKd=PYz(-SI1vkrXy|s@p{a{PZ+1@Q{eICcBV+TPB zNX!VD6D)LCK7TZ`(F|992FUu=hEZJC!vqv<3xIyu!#pqo7bqNVrNL8Hvm*mV2hvfm z;X6mn(wLeqt|>To1ztr*XqiWzPxT{cmnRV5PSrt*Qv1ij{-v-E<^aG0R|K@`4)6Rk zfV#QYPP~S|FxP$jNV8R`!X|@z`IZ4i5X#HvsqTF$e}ACSrWvNE;;RC`O>%_6amco~ zMc2HT3|h`p=L`mGb{X-DrLXvCt8om{L-!spaoy=EA66n_--Hrj{hRe98h%_;n5yT^ zD=WgdvvB}2yf*^V1lj8MAAghZ9FuJjj}QfhR{%d$;ZK1apJC#+_bOfa)nm6}dcdO8 z1H}n<#(yj_`HhP9Ho|7h@4Qt;jIk_%24?BVETbvjS}?ueS5@5jtkGAdbv9a1Z46qJ z#L|uPu`-;N^O_0(Y|*fRrtVJk1XC>abL>qafx;vC0WMf-4;TS;eH`EIhNi3oRaq^7 z&L?MQ2I@hp(yTAwJDq8kxy9ril?PvhoJg(C$QKP;B+Z=N7J-_ zQU3pnPZzW#S4^IrDL zV1Ff;icILR%F}j)7u|egWOjp9x#`D+IKG8S}xO zvi?e~B7UNgIcUV*3AyC#${4;TTfA!B6n}h04mSk{yPFS#;9F}`aDX5=ZY3su{DI?S z%{n+Lz@Vaf4lxBp8PQ~rADCzNHhlgI0e8^g=z4O;f2Tbm8-IC@jsG|)&ms=!lJb+E zBebto$>yB{RD{sJatvD~dZTUux`Q_@I%0`e*iPT`N4MJDqco2agt0|za7t|a;t{Bv=#M@f3k>_2r zngB9GC>>pEd^LX9<6DqT%vbusV}EJo(sbf>3w2FWdM*|i@GW5fP^i^mvE?$QcD7o4 z3|>-KvJh;lNxC4<^iVC|P|@=>PrfwRaas>YZ8#1uV{R`k1yrzhz09rk>p?{KUcuMy zXiP>Z-_uIftu$g$uUU*6SDycT7x|XAr}JuHZkQu*=XpF4#=z&7B%^@|0)JsmjLB1! zKl*+p296rm5t2&~eKXapgdS}8rR8RM>PY1yMx|e006x7)z!0npZ?}>-qoxE%Bh(|5 zfniooL5i|!KYGKg-!mrD!Y;QP1nEh~S!VeoS+{xY8%PNnJ-yt9sktXHUfxHwW*JNK zl68GvQg<&ErbCNbOZ{aNxqsbu*Xr+UpcxB4x>jx0jJ54@!4|xR#$_;%ib-IqI82A? z0n(;SkkaaNGDRgx5Lumok|U?bX7){nZX-S~`Ys_w;g7itobSC!<;$ajM)H>+``&5I zZG8-w9pPfH?3?~kSmLuZwKMv44qc5wXv^;3;0{UMa$7XNBiI-3&s zxoj*Suz3LQuC;`<3V%At^tfhf&PxjJn6NotF$!^WfLMsYIVNs}akKXsvJG+bL{v1S zO+-IAyQ7T0ddnOx%Lf39Rp=1wm&VxP#sWG~y=Zb5a6N$HB_xF`z3ul5LuKgE;;-hE zQ6vJ0a9G}VruLWb56*_t^6?53TL__Vy8tonY}xa>?6oWn%72PcpteEojuc;e6MkUy zQ@dcl7$75sq6>R56LToO5_ahp%Epky3U@4Kf&J|Nxfd;~fUFWy)iN|t^B z0U3hEyy*z>xb!}%FuT!Fl(LNC!^~52s;k{Ff2pZr3^Ka37~lnweF&jQ9dCtJI)08b zAig6K-mO&zwttpj;J;~tFb>wBLrSDj^LKTJ7wD)U9WMrhz;h-4_Q{Sq>>)=K*35;s zN!=-i$2uPPT%S~@3o@PQoe5P!K+NLNJ`JQt$5gEoj#3lG0^LnC?C2I?X}?Ap?Tw(c zlP{a?eOQPTK$cNul)T8m9ob0$t!;gsjk>}9iv>!xo`10uKy`FIXh264r?p_wC!xpx zo+b_6FO;?|cAb+uI3NsJ(9Xl`#1DpW!srT&zVE%394VCwzy`>v!UW6LKv|x6x#&Ym z9f^?$yPsv9^d{fSrz~sS6|V8P3sK7OYB=`)<$h#^#9l(@f(u|{J1xwRXe!m-nWQqx zPiKza4}Tq(w9r0AJNBrkQQQk?Y%AU(e#b$49i9NEkqpb;B4 z4JhMg&vVh7rZb*1Ny)!>Lw;$Ed8i65=+;uf1wRZiMfJLjR(BaKr8^II$=7fAb(r`l z7T)8=TJg?AT*&BYMcb#hiuf(iak#YRa32MKd4bb^j0$2T5`|ROo2|#OV?nXJyQMNW zIe)>f_Oc14E(2h_tS2J*EudgYG7KAcz)3W4zXh$Z22*Ae;NhZNw;lSTci%pn)6!Bz zK94wRX8p7ZN%v@%{I5j(Xg3hRJgRDT{MOfnMkfjeart zlwt=5{glg*(sA>F79l!t6#4x!uv=h2~@M_fhpGzP5442Qk$)$k zjVSF2A;CzmLg9wdLcty>sty1ZI`-V)5UAgUzTLM?C8KYC2f;N@D19rNph^q?vX&KB znGIb9MnWafEDgR(stOS2OMw((Fg3E?!&Z{&5x!VdZ2mm9#;e-5u`OUIZWVDcfamI-4mb@Z^Xn#~`i^jEJ zG~TRPpN;?rE8zdFW>SwjSBch@VupTWLc~+V|I+bmHNT!A$7Ej~sXnRvpH#KxrVMo9|DaO7qZkf=rsY-$GcG^RprYM;kaY0Ss(g zmU11j0*Sw!EmWR8r0tYvMSs*WJsI>e;7 z^@5emd@1XW6!8$INpyXU@lH(9i_B}dKzhkIa_qPlf?K}cTC3bX_sP!_I#tg9je=7t zCSFx>RQe0IrSjei2G22Z3dR70$A8>~b)D6!=|(cDFjJv@5g#_5FMrbwU-5&i;sz;) z9l+lIdOD2QxJDQOBMvU@FV0smo(qQ-ksxdt$5{V%h=*&#U$G*@6qZCxjB$HwUATG& z&|drlL<4WeD<#H&PrzZDpM~6b$IFv5(iE4^|BYAOa}X@Pq->NgRR_l$wxXTLBhu@t z8D5Oe*tGfim$nV|jeiNEWVzx4ur7$5o5%5&3cw`o2Zo8YsBTE)V9LAzr&O1TJX&4i z2%#b3B#V$Evkc3gM66CBl1QKmCtsKi(id6EF-I9fS5M}%6TF8Md{|m!J7%azITbb+ zb35MZ5#|3^C(1qe;3R{hml}7XzsV#4?~9XZsC>3Ig&~!54i?Rh-5L`T)OHHn!uszH L-3_cJxabNDeCTUo diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 1f132df8daf8..5338795c9683 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -31,7 +31,18 @@ import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { SubAgentActivityKind } from "./SubAgentActivityKind"; import type { UserInput } from "./UserInput"; -export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, delivery: AgentMessageDelivery | null, questions: Array | null, } | { "type": "functionCallOutput", id: string, name: string, namespace: string | null, output: FunctionCallOutputBody, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, +/** + * A thread item and its recorded lifecycle times. The payload stays flat on the wire. + */ +export type ThreadItem = { +/** + * Unix timestamp in milliseconds when the item started, if recorded. + */ +startedAtMs: number | null, +/** + * Unix timestamp in milliseconds when the item completed, if recorded. + */ +completedAtMs: number | null, } & ({ "type": "userMessage", id: string, clientId: string | null, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, delivery: AgentMessageDelivery | null, questions: Array | null, } | { "type": "functionCallOutput", id: string, name: string, namespace: string | null, output: FunctionCallOutputBody, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** * Trusted first-party plugin id when this command resolves to one plugin script. */ @@ -130,4 +141,4 @@ model: string | null, /** * Resolved reasoning effort at sub-agent creation, when known. */ -reasoningEffort: ReasoningEffort | null, id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep" } & SleepItem | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +reasoningEffort: ReasoningEffort | null, id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep" } & SleepItem | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }); diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs index e0fcfb6759d8..9ba6a32d7065 100644 --- a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -17,7 +17,7 @@ use crate::protocol::v2::ReasoningSummaryPartAddedNotification; use crate::protocol::v2::ReasoningSummaryTextDeltaNotification; use crate::protocol::v2::ReasoningTextDeltaNotification; use crate::protocol::v2::TerminalInteractionNotification; -use crate::protocol::v2::ThreadItem; +use crate::protocol::v2::ThreadItemPayload; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::protocol::EventMsg; use std::collections::HashMap; @@ -42,7 +42,7 @@ pub fn item_event_to_server_notification( DynamicToolCallStatus::Failed }; let duration_ms = i64::try_from(response.duration.as_millis()).ok(); - let item = ThreadItem::DynamicToolCall { + let item = ThreadItemPayload::DynamicToolCall { id: response.call_id, namespace: response.namespace, tool: response.tool, @@ -68,15 +68,16 @@ pub fn item_event_to_server_notification( success: Some(response.success), duration_ms, }; - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, - turn_id: response.turn_id, + response.turn_id, item, - completed_at_ms: response.completed_at_ms, - }) + /*started_at_ms*/ None, + response.completed_at_ms, + )) } EventMsg::CollabAgentSpawnBegin(begin_event) => { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::SpawnAgent, status: CollabAgentToolCallStatus::InProgress, @@ -87,12 +88,12 @@ pub fn item_event_to_server_notification( reasoning_effort: Some(begin_event.reasoning_effort), agents_states: HashMap::new(), }; - ServerNotification::ItemStarted(ItemStartedNotification { + ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id, turn_id, item, - started_at_ms: begin_event.started_at_ms, - }) + begin_event.started_at_ms, + )) } EventMsg::CollabAgentSpawnEnd(end_event) => { let has_receiver = end_event.new_thread_id.is_some(); @@ -115,7 +116,7 @@ pub fn item_event_to_server_notification( } None => (Vec::new(), HashMap::new()), }; - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::SpawnAgent, status, @@ -126,16 +127,17 @@ pub fn item_event_to_server_notification( reasoning_effort: Some(end_event.reasoning_effort), agents_states, }; - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, item, - completed_at_ms: end_event.completed_at_ms, - }) + /*started_at_ms*/ None, + end_event.completed_at_ms, + )) } EventMsg::CollabAgentInteractionBegin(begin_event) => { let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()]; - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::SendInput, status: CollabAgentToolCallStatus::InProgress, @@ -146,12 +148,12 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states: HashMap::new(), }; - ServerNotification::ItemStarted(ItemStartedNotification { + ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id, turn_id, item, - started_at_ms: begin_event.started_at_ms, - }) + begin_event.started_at_ms, + )) } EventMsg::CollabAgentInteractionEnd(end_event) => { let status = match &end_event.status { @@ -163,7 +165,7 @@ pub fn item_event_to_server_notification( }; let receiver_id = end_event.receiver_thread_id.to_string(); let received_status = CollabAgentState::from(end_event.status); - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::SendInput, status, @@ -174,15 +176,16 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states: [(receiver_id, received_status)].into_iter().collect(), }; - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, item, - completed_at_ms: end_event.completed_at_ms, - }) + /*started_at_ms*/ None, + end_event.completed_at_ms, + )) } EventMsg::SubAgentActivity(activity) => { - let item = ThreadItem::SubAgentActivity { + let item = ThreadItemPayload::SubAgentActivity { model: activity.model, reasoning_effort: activity.reasoning_effort, id: activity.event_id, @@ -190,12 +193,13 @@ pub fn item_event_to_server_notification( agent_thread_id: activity.agent_thread_id.to_string(), agent_path: String::from(activity.agent_path), }; - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, item, - completed_at_ms: activity.occurred_at_ms, - }) + /*started_at_ms*/ None, + activity.occurred_at_ms, + )) } EventMsg::CollabWaitingBegin(begin_event) => { let receiver_thread_ids = begin_event @@ -203,7 +207,7 @@ pub fn item_event_to_server_notification( .iter() .map(ToString::to_string) .collect(); - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::Wait, status: CollabAgentToolCallStatus::InProgress, @@ -214,12 +218,12 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states: HashMap::new(), }; - ServerNotification::ItemStarted(ItemStartedNotification { + ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id, turn_id, item, - started_at_ms: begin_event.started_at_ms, - }) + begin_event.started_at_ms, + )) } EventMsg::CollabWaitingEnd(end_event) => { let status = if end_event.statuses.values().any(|status| { @@ -239,7 +243,7 @@ pub fn item_event_to_server_notification( .iter() .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) .collect(); - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::Wait, status, @@ -250,15 +254,16 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states, }; - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, item, - completed_at_ms: end_event.completed_at_ms, - }) + /*started_at_ms*/ None, + end_event.completed_at_ms, + )) } EventMsg::CollabCloseBegin(begin_event) => { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::CloseAgent, status: CollabAgentToolCallStatus::InProgress, @@ -269,12 +274,12 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states: HashMap::new(), }; - ServerNotification::ItemStarted(ItemStartedNotification { + ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id, turn_id, item, - started_at_ms: begin_event.started_at_ms, - }) + begin_event.started_at_ms, + )) } EventMsg::CollabCloseEnd(end_event) => { let status = match &end_event.status { @@ -291,7 +296,7 @@ pub fn item_event_to_server_notification( )] .into_iter() .collect(); - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::CloseAgent, status, @@ -302,15 +307,16 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states, }; - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, item, - completed_at_ms: end_event.completed_at_ms, - }) + /*started_at_ms*/ None, + end_event.completed_at_ms, + )) } EventMsg::CollabResumeBegin(begin_event) => { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::ResumeAgent, status: CollabAgentToolCallStatus::InProgress, @@ -321,12 +327,12 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states: HashMap::new(), }; - ServerNotification::ItemStarted(ItemStartedNotification { + ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id, turn_id, item, - started_at_ms: begin_event.started_at_ms, - }) + begin_event.started_at_ms, + )) } EventMsg::CollabResumeEnd(end_event) => { let status = match &end_event.status { @@ -343,7 +349,7 @@ pub fn item_event_to_server_notification( )] .into_iter() .collect(); - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::ResumeAgent, status, @@ -354,12 +360,13 @@ pub fn item_event_to_server_notification( reasoning_effort: None, agents_states, }; - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, item, - completed_at_ms: end_event.completed_at_ms, - }) + /*started_at_ms*/ None, + end_event.completed_at_ms, + )) } EventMsg::AgentMessageContentDelta(event) => { let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } = @@ -404,20 +411,21 @@ pub fn item_event_to_server_notification( }) } EventMsg::ItemStarted(item_started_event) => { - ServerNotification::ItemStarted(ItemStartedNotification { + ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id, turn_id, - item: item_started_event.item.into(), - started_at_ms: item_started_event.started_at_ms, - }) + ThreadItemPayload::from(item_started_event.item), + item_started_event.started_at_ms, + )) } EventMsg::ItemCompleted(item_completed_event) => { - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, - item: item_completed_event.item.into(), - completed_at_ms: item_completed_event.completed_at_ms, - }) + ThreadItemPayload::from(item_completed_event.item), + item_completed_event.started_at_ms, + item_completed_event.completed_at_ms, + )) } EventMsg::PatchApplyUpdated(event) => { ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification { @@ -428,12 +436,12 @@ pub fn item_event_to_server_notification( }) } EventMsg::ExecCommandBegin(exec_command_begin_event) => { - ServerNotification::ItemStarted(ItemStartedNotification { + ServerNotification::ItemStarted(ItemStartedNotification::new( thread_id, turn_id, - item: build_command_execution_begin_item(&exec_command_begin_event), - started_at_ms: exec_command_begin_event.started_at_ms, - }) + build_command_execution_begin_item(&exec_command_begin_event), + exec_command_begin_event.started_at_ms, + )) } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { let item_id = exec_command_output_delta_event.call_id; @@ -457,12 +465,13 @@ pub fn item_event_to_server_notification( }) } EventMsg::ExecCommandEnd(exec_command_end_event) => { - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( thread_id, turn_id, - item: build_command_execution_end_item(&exec_command_end_event), - completed_at_ms: exec_command_end_event.completed_at_ms, - }) + build_command_execution_end_item(&exec_command_end_event), + /*started_at_ms*/ None, + exec_command_end_event.completed_at_ms, + )) } _ => unreachable!("unsupported item event"), } @@ -528,11 +537,10 @@ mod tests { ); assert_item_started_server_notification( notification, - ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: event.started_at_ms, - item: ThreadItem::CollabAgentToolCall { + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, status: CollabAgentToolCallStatus::InProgress, @@ -543,7 +551,8 @@ mod tests { reasoning_effort: None, agents_states: HashMap::new(), }, - }, + event.started_at_ms, + ), ); } @@ -567,11 +576,10 @@ mod tests { ); assert_item_completed_server_notification( notification, - ItemCompletedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn-2".to_string(), - completed_at_ms: event.completed_at_ms, - item: ThreadItem::CollabAgentToolCall { + ItemCompletedNotification::new( + "thread-2".to_string(), + "turn-2".to_string(), + ThreadItemPayload::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, status: CollabAgentToolCallStatus::Failed, @@ -587,7 +595,9 @@ mod tests { .into_iter() .collect(), }, - }, + /*started_at_ms*/ None, + event.completed_at_ms, + ), ); } diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 1ace0e8a6ebd..3f100534d7f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -21,7 +21,7 @@ use crate::protocol::v2::ItemGuardianApprovalReviewCompletedNotification; use crate::protocol::v2::ItemGuardianApprovalReviewStartedNotification; use crate::protocol::v2::PatchApplyStatus; use crate::protocol::v2::PatchChangeKind; -use crate::protocol::v2::ThreadItem; +use crate::protocol::v2::ThreadItemPayload; use codex_protocol::ThreadId; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -69,34 +69,34 @@ pub(crate) fn review_output_text(output: Option<&ReviewOutputEvent>) -> String { pub fn build_file_change_approval_request_item( payload: &ApplyPatchApprovalRequestEvent, -) -> ThreadItem { - ThreadItem::FileChange { +) -> ThreadItemPayload { + ThreadItemPayload::FileChange { id: payload.call_id.clone(), changes: convert_patch_changes(&payload.changes), status: PatchApplyStatus::InProgress, } } -pub fn build_file_change_begin_item(payload: &PatchApplyBeginEvent) -> ThreadItem { - ThreadItem::FileChange { +pub fn build_file_change_begin_item(payload: &PatchApplyBeginEvent) -> ThreadItemPayload { + ThreadItemPayload::FileChange { id: payload.call_id.clone(), changes: convert_patch_changes(&payload.changes), status: PatchApplyStatus::InProgress, } } -pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { - ThreadItem::FileChange { +pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItemPayload { + ThreadItemPayload::FileChange { id: payload.call_id.clone(), changes: convert_patch_changes(&payload.changes), status: (&payload.status).into(), } } -pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { +pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItemPayload { let presentation = CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); - ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: payload.call_id.clone(), @@ -114,7 +114,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th } } -pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> ThreadItem { +pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> ThreadItemPayload { let aggregated_output = if payload.aggregated_output.is_empty() { None } else { @@ -124,7 +124,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread let presentation = CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd); - ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: payload.call_id.clone(), @@ -193,7 +193,7 @@ fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> pub fn build_item_from_guardian_event( assessment: &GuardianAssessmentEvent, status: CommandExecutionStatus, -) -> Option { +) -> Option { match &assessment.action { GuardianAssessmentAction::Command { command, cwd, .. } => { let id = assessment.target_item_id.as_ref()?; @@ -201,7 +201,7 @@ pub fn build_item_from_guardian_event( let command_actions = vec![CommandAction::Unknown { command: command.clone(), }]; - Some(ThreadItem::CommandExecution { + Some(ThreadItemPayload::CommandExecution { id: id.clone(), model_context: assessment.model_context.clone(), sandbox_type: None, @@ -241,7 +241,7 @@ pub fn build_item_from_guardian_event( .map(|parsed| CommandAction::from_core_with_cwd(parsed, cwd)) .collect() }; - Some(ThreadItem::CommandExecution { + Some(ThreadItemPayload::CommandExecution { id: id.clone(), model_context: assessment.model_context.clone(), sandbox_type: None, diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs index f08082a8d9bf..e9509d78999f 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::protocol::thread_history::build_turns_from_rollout_items; +use crate::protocol::v2::ThreadItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::GuardianAssessmentStatus; @@ -126,7 +127,11 @@ fn guardian_stdin_reviews_preserve_parent_command_history() { assert_eq!(expected.len(), 2); assert_eq!( expected[0].items, - vec![build_command_execution_begin_item(&begin)], + [ThreadItem { + payload: build_command_execution_begin_item(&begin), + started_at_ms: Some(begin.started_at_ms), + completed_at_ms: None, + }], ); assert!(expected[1].items.is_empty()); diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 418a9fb1bb5c..077dfd5e0d69 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -17,6 +17,7 @@ use crate::protocol::v2::McpToolCallError; use crate::protocol::v2::McpToolCallResult; use crate::protocol::v2::McpToolCallStatus; use crate::protocol::v2::ThreadItem; +use crate::protocol::v2::ThreadItemPayload; use crate::protocol::v2::Turn; use crate::protocol::v2::TurnError as V2TurnError; use crate::protocol::v2::TurnError; @@ -53,6 +54,7 @@ use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; @@ -95,13 +97,52 @@ pub fn build_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { builder.finish() } +#[derive(Default)] +struct ItemTimes { + started_at_ms: Option, + completed_at_ms: Option, +} + +impl ItemTimes { + // Legacy events deserialize an omitted integer timestamp as zero. Leave the + // item untimed in that case so replay never shows a date in 1970. + fn legacy_start(started_at_ms: i64) -> Self { + Self { + started_at_ms: (started_at_ms != 0).then_some(started_at_ms), + ..Self::default() + } + } + + fn legacy_completion(completed_at_ms: i64) -> Self { + Self { + completed_at_ms: (completed_at_ms != 0).then_some(completed_at_ms), + ..Self::default() + } + } +} + +fn history_items_with_times( + items: Vec, + times: &HashMap, +) -> Vec { + items + .into_iter() + .map(|payload| { + let times = times.get(payload.id()); + ThreadItem { + started_at_ms: times.and_then(|t| t.started_at_ms), + completed_at_ms: times.and_then(|t| t.completed_at_ms), + payload, + } + }) + .collect() +} + /// A materialized `ThreadItem` snapshot that changed while handling one input. #[derive(Debug, Clone, PartialEq)] pub struct ThreadHistoryItemChange { pub turn_id: String, pub item: ThreadItem, - pub started_at_ms: Option, - pub completed_at_ms: Option, } /// Turn metadata for history projection and snapshots that do not need items. @@ -362,7 +403,19 @@ impl ThreadHistoryBuilder { /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `persisted_rollout_item` in `codex-rs/rollout/src/policy.rs`. pub fn handle_event(&mut self, event: &EventMsg) { + self.handle_event_for_history_mode(event, ThreadHistoryMode::Legacy); + } + + /// Paginated live history uses canonical items when Core's compatibility + /// events omit their IDs. Legacy rollouts have only the compatibility events. + pub fn handle_event_for_history_mode(&mut self, event: &EventMsg, mode: ThreadHistoryMode) { match event { + EventMsg::UserMessage(_) + | EventMsg::AgentMessage(_) + | EventMsg::AgentReasoning(_) + | EventMsg::AgentReasoningRawContent(_) + | EventMsg::ContextCompacted(_) + if mode == ThreadHistoryMode::Paginated => {} EventMsg::UserMessage(payload) => self.handle_user_message(payload), EventMsg::AgentMessage(payload) => self.handle_agent_message(payload), EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), @@ -410,8 +463,8 @@ impl ThreadHistoryBuilder { EventMsg::ContextCompacted(payload) => self.handle_context_compacted(payload), EventMsg::EnteredReviewMode(payload) => self.handle_entered_review_mode(payload), EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload), - EventMsg::ItemStarted(payload) => self.handle_item_started(payload), - EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), + EventMsg::ItemStarted(payload) => self.handle_item_started(payload, mode), + EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload, mode), EventMsg::HookStarted(_) | EventMsg::HookCompleted(_) => {} EventMsg::Error(payload) => self.handle_error(payload), EventMsg::TokenCount(_) => {} @@ -475,7 +528,19 @@ impl ThreadHistoryBuilder { debug_assert!(self.active_change_set.is_none()); self.active_change_set = Some(ThreadHistoryChangeSet::default()); handle(self); - self.active_change_set.take().unwrap_or_default() + let mut changes = self.active_change_set.take().unwrap_or_default(); + // Canonical events can record times after updating the payload. Publish + // the same lifecycle times a turn snapshot would return after this event. + for change in &mut changes.changed_items { + if let Some(times) = self + .turn_mut(&change.turn_id) + .and_then(|turn| turn.item_times.get(change.item.id())) + { + change.item.started_at_ms = times.started_at_ms; + change.item.completed_at_ms = times.completed_at_ms; + } + } + changes } fn handle_response_item(&mut self, item: &codex_protocol::models::ResponseItem) { @@ -494,7 +559,7 @@ impl ThreadHistoryBuilder { return; }; - self.push_item_in_current_turn(ThreadItem::HookPrompt { + self.push_item_in_current_turn(ThreadItemPayload::HookPrompt { id: hook_prompt.id, fragments: hook_prompt .fragments @@ -516,7 +581,7 @@ impl ThreadHistoryBuilder { } let id = self.next_item_id(); let content = self.build_user_inputs(payload); - self.push_item_in_current_turn(ThreadItem::UserMessage { + self.push_item_in_current_turn(ThreadItemPayload::UserMessage { id, client_id: payload.client_id.clone(), content, @@ -529,7 +594,7 @@ impl ThreadHistoryBuilder { } let id = self.next_item_id(); - self.push_item_in_current_turn(ThreadItem::AgentMessage { + self.push_item_in_current_turn(ThreadItemPayload::AgentMessage { id, text: payload.message.clone(), phase: payload.phase.clone(), @@ -548,7 +613,7 @@ impl ThreadHistoryBuilder { let existing_item_change = { let tracking_changes = self.is_tracking_changes(); let turn = self.ensure_turn(); - if let Some(ThreadItem::Reasoning { summary, .. }) = turn.items.last_mut() { + if let Some(ThreadItemPayload::Reasoning { summary, .. }) = turn.items.last_mut() { summary.push(payload.text.clone()); let changed_item = if tracking_changes { turn.items @@ -572,7 +637,7 @@ impl ThreadHistoryBuilder { // Otherwise, create a new reasoning item. let id = self.next_item_id(); - self.push_item_in_current_turn(ThreadItem::Reasoning { + self.push_item_in_current_turn(ThreadItemPayload::Reasoning { id, summary: vec![payload.text.clone()], content: Vec::new(), @@ -588,7 +653,7 @@ impl ThreadHistoryBuilder { let existing_item_change = { let tracking_changes = self.is_tracking_changes(); let turn = self.ensure_turn(); - if let Some(ThreadItem::Reasoning { content, .. }) = turn.items.last_mut() { + if let Some(ThreadItemPayload::Reasoning { content, .. }) = turn.items.last_mut() { content.push(payload.text.clone()); let changed_item = if tracking_changes { turn.items @@ -612,19 +677,62 @@ impl ThreadHistoryBuilder { // Otherwise, create a new reasoning item. let id = self.next_item_id(); - self.push_item_in_current_turn(ThreadItem::Reasoning { + self.push_item_in_current_turn(ThreadItemPayload::Reasoning { id, summary: Vec::new(), content: vec![payload.text.clone()], }); } - fn handle_item_started(&mut self, payload: &ItemStartedEvent) { + fn handle_item_started(&mut self, payload: &ItemStartedEvent, mode: ThreadHistoryMode) { self.handle_materialized_item_lifecycle(&payload.turn_id, &payload.item); + self.record_item_times( + &payload.turn_id, + &payload.item, + Some(payload.started_at_ms), + /*completed_at_ms*/ None, + mode, + ); } - fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { - self.handle_materialized_item_lifecycle(&payload.turn_id, &payload.item); + fn handle_item_completed(&mut self, payload: &ItemCompletedEvent, mode: ThreadHistoryMode) { + if mode == ThreadHistoryMode::Paginated && has_idless_legacy_copy(&payload.item) { + let item = ThreadItemPayload::from(payload.item.clone()); + if !matches!(&item, ThreadItemPayload::AgentMessage { text, .. } if text.is_empty()) { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } else { + self.handle_materialized_item_lifecycle(&payload.turn_id, &payload.item); + } + self.record_item_times( + &payload.turn_id, + &payload.item, + payload.started_at_ms, + (payload.completed_at_ms != 0).then_some(payload.completed_at_ms), + mode, + ); + } + + fn record_item_times( + &mut self, + turn_id: &str, + item: &codex_protocol::items::TurnItem, + started_at_ms: Option, + completed_at_ms: Option, + mode: ThreadHistoryMode, + ) { + // The compatibility events for these items assign synthetic IDs. A matching + // ID string alone cannot establish that a timestamp belongs to that item. + let keeps_item_id = mode == ThreadHistoryMode::Paginated || !has_idless_legacy_copy(item); + if keeps_item_id && let Some(turn) = self.turn_mut(turn_id) { + turn.record_item_times( + &item.id(), + ItemTimes { + started_at_ms, + completed_at_ms, + }, + ); + } } fn handle_materialized_item_lifecycle( @@ -660,7 +768,7 @@ impl ThreadHistoryBuilder { }; if should_upsert { - let item = ThreadItem::from(item.clone()); + let item = ThreadItemPayload::from(item.clone()); if is_review_mode_item { self.upsert_review_mode_item(Some(turn_id), item); } else { @@ -670,7 +778,7 @@ impl ThreadHistoryBuilder { } fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) { - let item = ThreadItem::WebSearch(WebSearchItem { + let item = ThreadItemPayload::WebSearch(WebSearchItem { id: payload.call_id.clone(), query: String::new(), action: None, @@ -680,7 +788,7 @@ impl ThreadHistoryBuilder { } fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) { - let item = ThreadItem::WebSearch(WebSearchItem { + let item = ThreadItemPayload::WebSearch(WebSearchItem { id: payload.call_id.clone(), query: payload.query.clone(), action: Some(web_search_action_from_core(payload.action.clone())), @@ -691,7 +799,8 @@ impl ThreadHistoryBuilder { fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) { let item = build_command_execution_begin_item(payload); - self.upsert_item_in_turn_id(&payload.turn_id, item); + let times = ItemTimes::legacy_start(payload.started_at_ms); + self.upsert_item(Some(&payload.turn_id), item, times); } fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) { @@ -701,7 +810,8 @@ impl ThreadHistoryBuilder { // background exit watcher when that process finally exits. By then, a // newer user turn may already have started. Route by event turn_id so // replay preserves the original turn association. - self.upsert_item_in_turn_id(&payload.turn_id, item); + let times = ItemTimes::legacy_completion(payload.completed_at_ms); + self.upsert_item(Some(&payload.turn_id), item, times); } fn handle_guardian_assessment(&mut self, payload: &GuardianAssessmentEvent) { @@ -711,25 +821,32 @@ impl ThreadHistoryBuilder { CommandExecutionStatus::Declined } GuardianAssessmentStatus::TimedOut => CommandExecutionStatus::Failed, + // Approval hands the command back to execution, which supplies its completion time. GuardianAssessmentStatus::Approved => return, }; let Some(item) = build_item_from_guardian_event(payload, status) else { return; }; - if payload.turn_id.is_empty() { - self.upsert_item_in_current_turn(item); - } else { - self.upsert_item_in_turn_id(&payload.turn_id, item); - } + self.upsert_item( + (!payload.turn_id.is_empty()).then_some(payload.turn_id.as_str()), + item, + ItemTimes { + completed_at_ms: payload.completed_at_ms, + ..ItemTimes::legacy_start(payload.started_at_ms) + }, + ); } fn handle_apply_patch_approval_request(&mut self, payload: &ApplyPatchApprovalRequestEvent) { - let item = build_file_change_approval_request_item(payload); - if payload.turn_id.is_empty() { - self.upsert_item_in_current_turn(item); - } else { - self.upsert_item_in_turn_id(&payload.turn_id, item); - } + // An approval can be the first event in the history for this file change. + self.upsert_item( + (!payload.turn_id.is_empty()).then_some(payload.turn_id.as_str()), + build_file_change_approval_request_item(payload), + ItemTimes { + started_at_ms: Some(payload.started_at_ms), + completed_at_ms: None, + }, + ); } fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) { @@ -754,7 +871,7 @@ impl ThreadHistoryBuilder { &mut self, payload: &codex_protocol::dynamic_tools::DynamicToolCallRequest, ) { - let item = ThreadItem::DynamicToolCall { + let item = ThreadItemPayload::DynamicToolCall { id: payload.call_id.clone(), namespace: payload.namespace.clone(), tool: payload.tool.clone(), @@ -764,11 +881,11 @@ impl ThreadHistoryBuilder { success: None, duration_ms: None, }; - if payload.turn_id.is_empty() { - self.upsert_item_in_current_turn(item); - } else { - self.upsert_item_in_turn_id(&payload.turn_id, item); - } + self.upsert_item( + (!payload.turn_id.is_empty()).then_some(payload.turn_id.as_str()), + item, + ItemTimes::legacy_start(payload.started_at_ms), + ); } fn handle_dynamic_tool_call_response(&mut self, payload: &DynamicToolCallResponseEvent) { @@ -778,7 +895,7 @@ impl ThreadHistoryBuilder { DynamicToolCallStatus::Failed }; let duration_ms = i64::try_from(payload.duration.as_millis()).ok(); - let item = ThreadItem::DynamicToolCall { + let item = ThreadItemPayload::DynamicToolCall { id: payload.call_id.clone(), namespace: payload.namespace.clone(), tool: payload.tool.clone(), @@ -788,15 +905,15 @@ impl ThreadHistoryBuilder { success: Some(payload.success), duration_ms, }; - if payload.turn_id.is_empty() { - self.upsert_item_in_current_turn(item); - } else { - self.upsert_item_in_turn_id(&payload.turn_id, item); - } + self.upsert_item( + (!payload.turn_id.is_empty()).then_some(payload.turn_id.as_str()), + item, + ItemTimes::legacy_completion(payload.completed_at_ms), + ); } fn handle_mcp_tool_call_begin(&mut self, payload: &McpToolCallBeginEvent) { - let item = ThreadItem::McpToolCall { + let item = ThreadItemPayload::McpToolCall { id: payload.call_id.clone(), server: payload.invocation.server.clone(), tool: payload.invocation.tool.clone(), @@ -854,7 +971,7 @@ impl ThreadHistoryBuilder { }), ), }; - let item = ThreadItem::McpToolCall { + let item = ThreadItemPayload::McpToolCall { id: payload.call_id.clone(), server: payload.invocation.server.clone(), tool: payload.invocation.tool.clone(), @@ -890,7 +1007,7 @@ impl ThreadHistoryBuilder { } fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) { - let item = ThreadItem::ImageView { + let item = ThreadItemPayload::ImageView { id: payload.call_id.clone(), path: payload.path.clone().into(), }; @@ -898,7 +1015,7 @@ impl ThreadHistoryBuilder { } fn handle_image_generation_begin(&mut self, payload: &ImageGenerationBeginEvent) { - let item = ThreadItem::ImageGeneration(ImageGenerationItem { + let item = ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: payload.call_id.clone(), status: String::new(), revised_prompt: None, @@ -916,7 +1033,7 @@ impl ThreadHistoryBuilder { } fn handle_image_generation_end(&mut self, payload: &ImageGenerationEndEvent) { - let item = ThreadItem::ImageGeneration(ImageGenerationItem { + let item = ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: payload.call_id.clone(), status: payload.status.clone(), revised_prompt: payload.revised_prompt.clone(), @@ -935,7 +1052,7 @@ impl ThreadHistoryBuilder { &mut self, payload: &codex_protocol::protocol::CollabAgentSpawnBeginEvent, ) { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::SpawnAgent, status: CollabAgentToolCallStatus::InProgress, @@ -946,7 +1063,8 @@ impl ThreadHistoryBuilder { reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states: HashMap::new(), }; - self.upsert_item_in_current_turn(item); + let times = ItemTimes::legacy_start(payload.started_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_agent_spawn_end( @@ -970,7 +1088,7 @@ impl ThreadHistoryBuilder { } None => (Vec::new(), HashMap::new()), }; - self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::SpawnAgent, status, @@ -980,14 +1098,16 @@ impl ThreadHistoryBuilder { model: Some(payload.model.clone()), reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states, - }); + }; + let times = ItemTimes::legacy_completion(payload.completed_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_agent_interaction_begin( &mut self, payload: &codex_protocol::protocol::CollabAgentInteractionBeginEvent, ) { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::SendInput, status: CollabAgentToolCallStatus::InProgress, @@ -998,7 +1118,8 @@ impl ThreadHistoryBuilder { reasoning_effort: None, agents_states: HashMap::new(), }; - self.upsert_item_in_current_turn(item); + let times = ItemTimes::legacy_start(payload.started_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_agent_interaction_end( @@ -1011,7 +1132,7 @@ impl ThreadHistoryBuilder { }; let receiver_id = payload.receiver_thread_id.to_string(); let received_status = CollabAgentState::from(payload.status.clone()); - self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::SendInput, status, @@ -1021,28 +1142,32 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states: [(receiver_id, received_status)].into_iter().collect(), - }); + }; + let times = ItemTimes::legacy_completion(payload.completed_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_sub_agent_activity( &mut self, payload: &codex_protocol::protocol::SubAgentActivityEvent, ) { - self.upsert_item_in_current_turn(ThreadItem::SubAgentActivity { + let item = ThreadItemPayload::SubAgentActivity { model: payload.model.clone(), reasoning_effort: payload.reasoning_effort.clone(), id: payload.event_id.clone(), kind: payload.kind.into(), agent_thread_id: payload.agent_thread_id.to_string(), agent_path: String::from(payload.agent_path.clone()), - }); + }; + let times = ItemTimes::legacy_completion(payload.occurred_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_waiting_begin( &mut self, payload: &codex_protocol::protocol::CollabWaitingBeginEvent, ) { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::Wait, status: CollabAgentToolCallStatus::InProgress, @@ -1057,7 +1182,8 @@ impl ThreadHistoryBuilder { reasoning_effort: None, agents_states: HashMap::new(), }; - self.upsert_item_in_current_turn(item); + let times = ItemTimes::legacy_start(payload.started_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_waiting_end( @@ -1081,7 +1207,7 @@ impl ThreadHistoryBuilder { .iter() .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) .collect(); - self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::Wait, status, @@ -1091,14 +1217,16 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states, - }); + }; + let times = ItemTimes::legacy_completion(payload.completed_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_close_begin( &mut self, payload: &codex_protocol::protocol::CollabCloseBeginEvent, ) { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::CloseAgent, status: CollabAgentToolCallStatus::InProgress, @@ -1109,7 +1237,8 @@ impl ThreadHistoryBuilder { reasoning_effort: None, agents_states: HashMap::new(), }; - self.upsert_item_in_current_turn(item); + let times = ItemTimes::legacy_start(payload.started_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_close_end(&mut self, payload: &codex_protocol::protocol::CollabCloseEndEvent) { @@ -1124,7 +1253,7 @@ impl ThreadHistoryBuilder { )] .into_iter() .collect(); - self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::CloseAgent, status, @@ -1134,14 +1263,16 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states, - }); + }; + let times = ItemTimes::legacy_completion(payload.completed_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_resume_begin( &mut self, payload: &codex_protocol::protocol::CollabResumeBeginEvent, ) { - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::ResumeAgent, status: CollabAgentToolCallStatus::InProgress, @@ -1152,7 +1283,8 @@ impl ThreadHistoryBuilder { reasoning_effort: None, agents_states: HashMap::new(), }; - self.upsert_item_in_current_turn(item); + let times = ItemTimes::legacy_start(payload.started_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_collab_resume_end( @@ -1170,7 +1302,7 @@ impl ThreadHistoryBuilder { )] .into_iter() .collect(); - self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::ResumeAgent, status, @@ -1180,12 +1312,14 @@ impl ThreadHistoryBuilder { model: None, reasoning_effort: None, agents_states, - }); + }; + let times = ItemTimes::legacy_completion(payload.completed_at_ms); + self.upsert_item(/*turn_id*/ None, item, times); } fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) { let id = self.next_item_id(); - self.push_item_in_current_turn(ThreadItem::ContextCompaction { id }); + self.push_item_in_current_turn(ThreadItemPayload::ContextCompaction { id }); } fn handle_entered_review_mode( @@ -1202,7 +1336,7 @@ impl ThreadHistoryBuilder { .unwrap_or_else(|| self.next_item_id()); self.upsert_review_mode_item( payload.turn_id.as_deref(), - ThreadItem::EnteredReviewMode { id, review }, + ThreadItemPayload::EnteredReviewMode { id, review }, ); } @@ -1217,11 +1351,11 @@ impl ThreadHistoryBuilder { .unwrap_or_else(|| self.next_item_id()); self.upsert_review_mode_item( payload.turn_id.as_deref(), - ThreadItem::ExitedReviewMode { id, review }, + ThreadItemPayload::ExitedReviewMode { id, review }, ); } - fn upsert_review_mode_item(&mut self, turn_id: Option<&str>, item: ThreadItem) { + fn upsert_review_mode_item(&mut self, turn_id: Option<&str>, item: ThreadItemPayload) { let Some(turn_id) = turn_id else { self.upsert_item_in_current_turn(item); return; @@ -1425,6 +1559,7 @@ impl ThreadHistoryBuilder { root_turn_id: None, items: Vec::new(), item_index: TurnItemIndex::default(), + item_times: HashMap::new(), error: None, status: TurnStatus::Completed, started_at: None, @@ -1450,7 +1585,7 @@ impl ThreadHistoryBuilder { unreachable!("current turn must exist after initialization"); } - fn push_item_in_current_turn(&mut self, item: ThreadItem) { + fn push_item_in_current_turn(&mut self, item: ThreadItemPayload) { let tracking_changes = self.is_tracking_changes(); let changed_item = { let turn = self.ensure_turn(); @@ -1463,46 +1598,40 @@ impl ThreadHistoryBuilder { } } - fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItem) { - let tracking_changes = self.is_tracking_changes(); - if let Some(turn) = self.current_turn.as_mut() - && turn.id == turn_id - { - let changed_item = { - let item = turn.item_index.upsert(&mut turn.items, item); - tracking_changes.then(|| (turn.id.clone(), item.clone())) - }; - if let Some((turn_id, item)) = changed_item { - self.record_changed_item(turn_id, item); - } - return; - } + fn turn_mut(&mut self, turn_id: &str) -> Option<&mut PendingTurn> { + self.current_turn + .as_mut() + .filter(|turn| turn.id == turn_id) + .or_else(|| self.turns.iter_mut().find(|turn| turn.id == turn_id)) + } - if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { - let changed_item = { - let item = turn.item_index.upsert(&mut turn.items, item); - tracking_changes.then(|| (turn.id.clone(), item.clone())) - }; - if let Some((turn_id, item)) = changed_item { - self.record_changed_item(turn_id, item); - } - return; - } + fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItemPayload) { + self.upsert_item(Some(turn_id), item, ItemTimes::default()); + } - warn!( - item_id = item.id(), - "dropping turn-scoped item for unknown turn id `{turn_id}`" - ); + fn upsert_item_in_current_turn(&mut self, item: ThreadItemPayload) { + self.upsert_item(/*turn_id*/ None, item, ItemTimes::default()); } - fn upsert_item_in_current_turn(&mut self, item: ThreadItem) { + fn upsert_item(&mut self, turn_id: Option<&str>, item: ThreadItemPayload, times: ItemTimes) { let tracking_changes = self.is_tracking_changes(); - let changed_item = { - let turn = self.ensure_turn(); - let item = turn.item_index.upsert(&mut turn.items, item); - tracking_changes.then(|| (turn.id.clone(), item.clone())) + let turn = if let Some(turn_id) = turn_id { + let Some(turn) = self.turn_mut(turn_id) else { + warn!( + item_id = item.id(), + "dropping turn-scoped item for unknown turn id `{turn_id}`" + ); + return; + }; + turn + } else { + self.ensure_turn() }; - if let Some((turn_id, item)) = changed_item { + turn.record_item_times(item.id(), times); + let item = turn.item_index.upsert(&mut turn.items, item); + if tracking_changes { + let turn_id = turn.id.clone(); + let item = item.clone(); self.record_changed_item(turn_id, item); } } @@ -1511,14 +1640,11 @@ impl ThreadHistoryBuilder { self.active_change_set.is_some() } - fn record_changed_item(&mut self, turn_id: String, item: ThreadItem) { + fn record_changed_item(&mut self, turn_id: String, item: ThreadItemPayload) { if let Some(change_set) = self.active_change_set.as_mut() { change_set.changed_items.push(ThreadHistoryItemChange { turn_id, - item, - // Legacy events used by ThreadHistoryBuilder don't have timestamps - started_at_ms: None, - completed_at_ms: None, + item: ThreadItem::untimed(item), }); } } @@ -1636,6 +1762,18 @@ impl ThreadHistoryBuilder { } } +/// These items get synthetic IDs when reconstructed from compatibility events. +fn has_idless_legacy_copy(item: &codex_protocol::items::TurnItem) -> bool { + use codex_protocol::items::TurnItem; + matches!( + item, + TurnItem::UserMessage(_) + | TurnItem::AgentMessage(_) + | TurnItem::Reasoning(_) + | TurnItem::ContextCompaction(_) + ) +} + fn convert_dynamic_tool_content_items( items: &[codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem], ) -> Vec { @@ -1667,7 +1805,7 @@ struct TurnItemIndex { } impl TurnItemIndex { - fn push(&mut self, items: &mut Vec, item: ThreadItem) { + fn push(&mut self, items: &mut Vec, item: ThreadItemPayload) { if let Some(positions) = &mut self.positions { positions .entry(item.id().to_string()) @@ -1676,7 +1814,11 @@ impl TurnItemIndex { items.push(item); } - fn upsert<'a>(&mut self, items: &'a mut Vec, item: ThreadItem) -> &'a ThreadItem { + fn upsert<'a>( + &mut self, + items: &'a mut Vec, + item: ThreadItemPayload, + ) -> &'a ThreadItemPayload { if self.positions.is_none() && items.len() >= TURN_ITEM_INDEX_THRESHOLD { let mut positions = HashMap::with_capacity(items.len()); for (index, existing) in items.iter().enumerate() { @@ -1703,8 +1845,9 @@ impl TurnItemIndex { struct PendingTurn { id: String, root_turn_id: Option, - items: Vec, + items: Vec, item_index: TurnItemIndex, + item_times: HashMap, error: Option, status: TurnStatus, started_at: Option, @@ -1721,6 +1864,16 @@ struct PendingTurn { } impl PendingTurn { + fn record_item_times(&mut self, item_id: &str, times: ItemTimes) { + if times.started_at_ms.is_some() || times.completed_at_ms.is_some() { + let recorded = self.item_times.entry(item_id.to_owned()).or_default(); + // Canonical and compatibility events can describe the same item. + // Each fills in only the part of its lifecycle we have yet to observe. + recorded.started_at_ms = recorded.started_at_ms.or(times.started_at_ms); + recorded.completed_at_ms = recorded.completed_at_ms.or(times.completed_at_ms); + } + } + fn opened_explicitly(mut self) -> Self { self.opened_explicitly = true; self @@ -1737,10 +1890,11 @@ impl PendingTurn { } fn snapshot(&self, items_view: TurnItemsView) -> Turn { + let items = items_view.project_items(&self.items); Turn { id: self.id.clone(), root_turn_id: self.root_turn_id.clone(), - items: items_view.project_items(&self.items), + items: history_items_with_times(items, &self.item_times), items_view, error: self.error.clone(), status: self.status.clone(), @@ -1756,7 +1910,7 @@ impl From for Turn { Self { id: value.id, root_turn_id: value.root_turn_id, - items: value.items, + items: history_items_with_times(value.items, &value.item_times), items_view: TurnItemsView::Full, error: value.error, status: value.status, @@ -1829,8 +1983,8 @@ mod tests { use std::time::Duration; use uuid::Uuid; - fn indexed_sleep_item(id: &str, duration_ms: u64) -> ThreadItem { - ThreadItem::Sleep(CoreSleepItem { + fn indexed_sleep_item(id: &str, duration_ms: u64) -> ThreadItemPayload { + ThreadItemPayload::Sleep(CoreSleepItem { id: id.to_string(), duration_ms, }) @@ -1864,6 +2018,62 @@ mod tests { } } + #[test] + fn paginated_snapshot_keeps_reasoning_and_compaction_lifecycle() { + use codex_protocol::items::ContextCompactionItem; + use codex_protocol::items::ReasoningItem; + use codex_protocol::protocol::HasLegacyEvent; + + let mode = ThreadHistoryMode::Paginated; + let mut builder = ThreadHistoryBuilder::new(); + let turn_id = "turn-1".to_string(); + builder.handle_event_for_history_mode( + &EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.clone(), + turn_attribution: None, + root_turn_id: None, + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + mode, + ); + let mut expected = Vec::new(); + for item in [ + CoreTurnItem::Reasoning(ReasoningItem { + id: "reasoning".into(), + summary_text: vec!["summary".into()], + raw_content: vec!["content".into()], + }), + CoreTurnItem::ContextCompaction(ContextCompactionItem { + id: "compaction".into(), + }), + ] { + let started = ItemStartedEvent { + thread_id: ThreadId::default(), + turn_id: turn_id.clone(), + item: item.clone(), + started_at_ms: 10, + }; + builder.handle_event_for_history_mode(&EventMsg::ItemStarted(started), mode); + let completed = ItemCompletedEvent { + thread_id: ThreadId::default(), + turn_id: turn_id.clone(), + item, + started_at_ms: Some(10), + completed_at_ms: 20, + }; + expected.push(ThreadItem::from(&completed)); + let copies = completed.as_legacy_events(/*show_raw_agent_reasoning*/ true); + builder.handle_event_for_history_mode(&EventMsg::ItemCompleted(completed), mode); + for copy in copies { + builder.handle_event_for_history_mode(©, mode); + } + } + assert_eq!(builder.active_turn_snapshot().unwrap().items, expected); + } + #[test] fn builds_multiple_turns_with_reasoning_items() { let events = vec![ @@ -1917,8 +2127,8 @@ mod tests { assert_eq!(first.status, TurnStatus::Completed); assert_eq!(first.items.len(), 3); assert_eq!( - first.items[0], - ThreadItem::UserMessage { + first.items[0].payload, + ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![ @@ -1936,8 +2146,8 @@ mod tests { } ); assert_eq!( - first.items[1], - ThreadItem::AgentMessage { + first.items[1].payload, + ThreadItemPayload::AgentMessage { id: "item-2".into(), text: "Hi there".into(), phase: None, @@ -1947,8 +2157,8 @@ mod tests { } ); assert_eq!( - first.items[2], - ThreadItem::Reasoning { + first.items[2].payload, + ThreadItemPayload::Reasoning { id: "item-3".into(), summary: vec!["thinking".into()], content: vec!["full reasoning".into()], @@ -1960,8 +2170,8 @@ mod tests { assert_ne!(first.id, second.id); assert_eq!(second.items.len(), 2); assert_eq!( - second.items[0], - ThreadItem::UserMessage { + second.items[0].payload, + ThreadItemPayload::UserMessage { id: "item-4".into(), client_id: None, content: vec![UserInput::Text { @@ -1971,8 +2181,8 @@ mod tests { } ); assert_eq!( - second.items[1], - ThreadItem::AgentMessage { + second.items[1].payload, + ThreadItemPayload::AgentMessage { id: "item-5".into(), text: "Reply two".into(), phase: None, @@ -2019,13 +2229,13 @@ mod tests { assert_eq!(turns[0].id, "turn-1"); assert_eq!( - turns[0].items, - vec![ - ThreadItem::EnteredReviewMode { + turns[0].item_payloads().collect::>(), + [ + &ThreadItemPayload::EnteredReviewMode { id: "entered-review".into(), review: "Review requested.".into(), }, - ThreadItem::ExitedReviewMode { + &ThreadItemPayload::ExitedReviewMode { id: "exited-review".into(), review: REVIEW_FALLBACK_MESSAGE.into(), }, @@ -2082,13 +2292,13 @@ mod tests { assert_eq!(turns[0].id, "turn-1"); assert_eq!( - turns[0].items, - vec![ - ThreadItem::EnteredReviewMode { + turns[0].item_payloads().collect::>(), + [ + &ThreadItemPayload::EnteredReviewMode { id: "entered-review".into(), review: "Review requested.".into(), }, - ThreadItem::ExitedReviewMode { + &ThreadItemPayload::ExitedReviewMode { id: "exited-review".into(), review: REVIEW_FALLBACK_MESSAGE.into(), }, @@ -2121,8 +2331,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!( - turns[0].items[0], - ThreadItem::UserMessage { + turns[0].items[0].payload, + ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![ @@ -2172,8 +2382,8 @@ mod tests { let turns = build_turns_from_rollout_items(&events); assert_eq!( - turns[0].items[0], - ThreadItem::UserMessage { + turns[0].items[0].payload, + ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![ @@ -2246,8 +2456,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 1); assert_eq!( - turns[0].items[0], - ThreadItem::UserMessage { + turns[0].items[0].payload, + ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { @@ -2303,12 +2513,19 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!( - turns[0].items, - vec![ThreadItem::Sleep(CoreSleepItem { + turns[0].item_payloads().collect::>(), + [&ThreadItemPayload::Sleep(CoreSleepItem { id: "sleep-1".to_string(), duration_ms: 1_000, })] ); + assert_eq!( + ( + turns[0].items[0].started_at_ms, + turns[0].items[0].completed_at_ms + ), + (Some(0), Some(1_000)) + ); } #[test] @@ -2329,7 +2546,10 @@ mod tests { builder.handle_thread_rollback(&ThreadRolledBackEvent { num_turns: 1 }); expected[0] = indexed_sleep_item("0", /*duration_ms*/ 2); builder.upsert_item_in_turn_id("retained", expected[0].clone()); - assert_eq!(builder.finish()[0].items, expected); + assert_eq!( + builder.finish()[0].item_payloads().collect::>(), + expected.iter().collect::>() + ); } #[test] @@ -2396,8 +2616,8 @@ mod tests { let turns = build_turns_from_rollout_items(&items); assert_eq!( - turns[0].items, - vec![ThreadItem::ImageGeneration(ImageGenerationItem { + turns[0].item_payloads().collect::>(), + [&ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: "image-1".to_string(), status: "completed".to_string(), revised_prompt: Some("A blue square".to_string()), @@ -2511,8 +2731,10 @@ mod tests { .collect::>(); assert_eq!( - build_turns_from_rollout_items(&items[..2])[0].items, - vec![ThreadItem::CommandExecution { + build_turns_from_rollout_items(&items[..2])[0] + .item_payloads() + .collect::>(), + [&ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "exec-1".to_string(), @@ -2538,8 +2760,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!( - turns[0].items, - vec![ThreadItem::CommandExecution { + turns[0].item_payloads().collect::>(), + [&ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "exec-1".to_string(), @@ -2618,14 +2840,14 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!( turns[0].items, - vec![ThreadItem::UserMessage { + [ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: Some("client-message-1".to_string()), content: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - }] + })] ); } @@ -2656,8 +2878,8 @@ mod tests { let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 1); assert_eq!( - turns[0].items[0], - ThreadItem::AgentMessage { + turns[0].items[0].payload, + ThreadItemPayload::AgentMessage { id: "item-1".into(), text: "Final reply".into(), phase: Some(CoreMessagePhase::FinalAnswer), @@ -2734,15 +2956,15 @@ mod tests { duration_ms: None, items_view: TurnItemsView::Full, items: vec![ - ThreadItem::UserMessage { + ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), }], - }, - ThreadItem::ImageGeneration(ImageGenerationItem { + }), + ThreadItem::untimed(ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: "ig_123".into(), status: "completed".into(), revised_prompt: Some("final prompt".into()), @@ -2763,7 +2985,7 @@ mod tests { }], imagegen_request_id: None, generation_id: None, - }), + })), ], } ); @@ -2808,16 +3030,16 @@ mod tests { assert_eq!(turn.items.len(), 4); assert_eq!( - turn.items[1], - ThreadItem::Reasoning { + turn.items[1].payload, + ThreadItemPayload::Reasoning { id: "item-2".into(), summary: vec!["first summary".into()], content: vec!["first content".into()], } ); assert_eq!( - turn.items[3], - ThreadItem::Reasoning { + turn.items[3].payload, + ThreadItemPayload::Reasoning { id: "item-4".into(), summary: vec!["second summary".into()], content: Vec::new(), @@ -2880,8 +3102,8 @@ mod tests { assert_eq!(first_turn.status, TurnStatus::Interrupted); assert_eq!(first_turn.items.len(), 2); assert_eq!( - first_turn.items[0], - ThreadItem::UserMessage { + first_turn.items[0].payload, + ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { @@ -2891,8 +3113,8 @@ mod tests { } ); assert_eq!( - first_turn.items[1], - ThreadItem::AgentMessage { + first_turn.items[1].payload, + ThreadItemPayload::AgentMessage { id: "item-2".into(), text: "Working...".into(), phase: None, @@ -2906,8 +3128,8 @@ mod tests { assert_eq!(second_turn.status, TurnStatus::Completed); assert_eq!(second_turn.items.len(), 2); assert_eq!( - second_turn.items[0], - ThreadItem::UserMessage { + second_turn.items[0].payload, + ThreadItemPayload::UserMessage { id: "item-3".into(), client_id: None, content: vec![UserInput::Text { @@ -2917,8 +3139,8 @@ mod tests { } ); assert_eq!( - second_turn.items[1], - ThreadItem::AgentMessage { + second_turn.items[1].payload, + ThreadItemPayload::AgentMessage { id: "item-4".into(), text: "Second attempt complete.".into(), phase: None, @@ -2992,9 +3214,9 @@ mod tests { assert_eq!(turns[0].status, TurnStatus::Completed); assert_eq!(turns[1].status, TurnStatus::Completed); assert_eq!( - turns[0].items, - vec![ - ThreadItem::UserMessage { + turns[0].item_payloads().collect::>(), + [ + &ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { @@ -3002,7 +3224,7 @@ mod tests { text_elements: Vec::new(), }], }, - ThreadItem::AgentMessage { + &ThreadItemPayload::AgentMessage { id: "item-2".into(), text: "A1".into(), phase: None, @@ -3013,9 +3235,9 @@ mod tests { ] ); assert_eq!( - turns[1].items, - vec![ - ThreadItem::UserMessage { + turns[1].item_payloads().collect::>(), + [ + &ThreadItemPayload::UserMessage { id: "item-3".into(), client_id: None, content: vec![UserInput::Text { @@ -3023,7 +3245,7 @@ mod tests { text_elements: Vec::new(), }], }, - ThreadItem::AgentMessage { + &ThreadItemPayload::AgentMessage { id: "item-4".into(), text: "A3".into(), phase: None, @@ -3127,9 +3349,9 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].id, "turn-a"); assert_eq!( - turns[0].items, - vec![ - ThreadItem::UserMessage { + turns[0].item_payloads().collect::>(), + [ + &ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { @@ -3137,7 +3359,7 @@ mod tests { text_elements: Vec::new(), }], }, - ThreadItem::UserMessage { + &ThreadItemPayload::UserMessage { id: "item-2".into(), client_id: None, content: vec![UserInput::Text { @@ -3230,8 +3452,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 4); assert_eq!( - turns[0].items[1], - ThreadItem::WebSearch(WebSearchItem { + turns[0].items[1].payload, + ThreadItemPayload::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { @@ -3246,8 +3468,8 @@ mod tests { }) ); assert_eq!( - turns[0].items[2], - ThreadItem::CommandExecution { + turns[0].items[2].payload, + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "exec-1".into(), @@ -3267,8 +3489,8 @@ mod tests { } ); assert_eq!( - turns[0].items[3], - ThreadItem::McpToolCall { + turns[0].items[3].payload, + ThreadItemPayload::McpToolCall { id: "mcp-1".into(), server: "docs".into(), tool: "lookup".into(), @@ -3338,8 +3560,8 @@ mod tests { let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 1); assert_eq!( - turns[0].items[0], - ThreadItem::McpToolCall { + turns[0].items[0].payload, + ThreadItemPayload::McpToolCall { id: "mcp-1".into(), server: "docs".into(), tool: "lookup".into(), @@ -3434,8 +3656,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 2); assert_eq!( - turns[0].items[1], - ThreadItem::DynamicToolCall { + turns[0].items[1].payload, + ThreadItemPayload::DynamicToolCall { id: "dyn-1".into(), namespace: Some("codex_app".into()), tool: "lookup_ticket".into(), @@ -3521,8 +3743,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 3); assert_eq!( - turns[0].items[1], - ThreadItem::CommandExecution { + turns[0].items[1].payload, + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "exec-declined".into(), @@ -3542,8 +3764,8 @@ mod tests { } ); assert_eq!( - turns[0].items[2], - ThreadItem::FileChange { + turns[0].items[2].payload, + ThreadItemPayload::FileChange { id: "patch-declined".into(), changes: vec![FileUpdateChange { path: "README.md".into(), @@ -3632,9 +3854,14 @@ mod tests { let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 2); + let item = &turns[0].items[1]; + assert_eq!( + (item.started_at_ms, item.completed_at_ms), + (Some(1_000), Some(1_042)) + ); assert_eq!( - turns[0].items[1], - ThreadItem::CommandExecution { + item.payload, + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "guardian-exec".into(), @@ -3709,8 +3936,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 2); assert_eq!( - turns[0].items[1], - ThreadItem::CommandExecution { + turns[0].items[1].payload, + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "guardian-execve".into(), @@ -3808,7 +4035,9 @@ mod tests { let mut expected = build_turns_from_rollout_items(&items); expected[0] .items - .push(ThreadItem::from(completion.item.clone())); + .push(ThreadItem::untimed(ThreadItemPayload::from( + completion.item.clone(), + ))); let legacy = completion.as_legacy_events(/*show_raw_agent_reasoning*/ false); let serialized = serde_json::to_string(&legacy[0]).unwrap(); @@ -3917,8 +4146,8 @@ mod tests { assert_eq!(turns[0].items.len(), 2); assert_eq!(turns[1].items.len(), 1); assert_eq!( - turns[0].items[1], - ThreadItem::CommandExecution { + turns[0].items[1].payload, + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "exec-late".into(), @@ -4028,8 +4257,8 @@ mod tests { assert_eq!(turns[0].items.len(), 1); assert_eq!(turns[1].items.len(), 1); assert_eq!( - turns[1].items[0], - ThreadItem::UserMessage { + turns[1].items[0].payload, + ThreadItemPayload::UserMessage { id: "item-2".into(), client_id: None, content: vec![UserInput::Text { @@ -4222,9 +4451,9 @@ mod tests { assert_eq!(snapshot.id, turn_id); assert_eq!(snapshot.status, TurnStatus::InProgress); assert_eq!( - snapshot.items, - vec![ - ThreadItem::UserMessage { + snapshot.item_payloads().collect::>(), + [ + &ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { @@ -4232,7 +4461,7 @@ mod tests { text_elements: Vec::new(), }], }, - ThreadItem::FileChange { + &ThreadItemPayload::FileChange { id: "patch-call".into(), changes: vec![FileUpdateChange { path: "README.md".into(), @@ -4270,7 +4499,7 @@ mod tests { EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "patch-call".into(), turn_id: turn_id.to_string(), - started_at_ms: 0, + started_at_ms: 1_250, changes: [( PathBuf::from("README.md"), codex_protocol::protocol::FileChange::Add { @@ -4293,10 +4522,11 @@ mod tests { .expect("active turn snapshot"); assert_eq!(snapshot.id, turn_id); assert_eq!(snapshot.status, TurnStatus::InProgress); + assert_eq!(snapshot.items[1].started_at_ms, Some(1_250)); assert_eq!( - snapshot.items, - vec![ - ThreadItem::UserMessage { + snapshot.item_payloads().collect::>(), + [ + &ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { @@ -4304,7 +4534,7 @@ mod tests { text_elements: Vec::new(), }], }, - ThreadItem::FileChange { + &ThreadItemPayload::FileChange { id: "patch-call".into(), changes: vec![FileUpdateChange { path: "README.md".into(), @@ -4469,14 +4699,14 @@ mod tests { id: "turn-a".into(), root_turn_id: None, items_view: TurnItemsView::Full, - items: vec![ThreadItem::UserMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { text: "first".into(), text_elements: Vec::new(), }], - }], + })], status: TurnStatus::Failed, error: Some(TurnError { misalignment: None, @@ -4495,14 +4725,14 @@ mod tests { id: "turn-b".into(), root_turn_id: None, items_view: TurnItemsView::Full, - items: vec![ThreadItem::UserMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "item-2".into(), client_id: None, content: vec![UserInput::Text { text: "second".into(), text_elements: Vec::new(), }], - }], + })], status: TurnStatus::InProgress, error: None, started_at: Some(30), @@ -4658,7 +4888,7 @@ mod tests { }), EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent { call_id: "resume-1".into(), - completed_at_ms: 0, + completed_at_ms: 2_000, sender_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") .expect("valid sender thread id"), receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") @@ -4676,9 +4906,14 @@ mod tests { let turns = build_turns_from_rollout_items(&items); assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 2); + let item = &turns[0].items[1]; assert_eq!( - turns[0].items[1], - ThreadItem::CollabAgentToolCall { + (item.started_at_ms, item.completed_at_ms), + (None, Some(2_000)) + ); + assert_eq!( + item.payload, + ThreadItemPayload::CollabAgentToolCall { id: "resume-1".into(), tool: CollabAgentTool::ResumeAgent, status: CollabAgentToolCallStatus::Completed, @@ -4737,8 +4972,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 2); assert_eq!( - turns[0].items[1], - ThreadItem::CollabAgentToolCall { + turns[0].items[1].payload, + ThreadItemPayload::CollabAgentToolCall { id: "spawn-1".into(), tool: CollabAgentTool::SpawnAgent, status: CollabAgentToolCallStatus::Completed, @@ -4809,8 +5044,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 2); assert_eq!( - turns[0].items[1], - ThreadItem::CollabAgentToolCall { + turns[0].items[1].payload, + ThreadItemPayload::CollabAgentToolCall { id: "send-1".into(), tool: CollabAgentTool::SendInput, status: CollabAgentToolCallStatus::Completed, @@ -4921,14 +5156,14 @@ mod tests { completed_at: None, duration_ms: None, items_view: TurnItemsView::Full, - items: vec![ThreadItem::UserMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - }], + })], } ); } @@ -5042,14 +5277,14 @@ mod tests { id: "turn-a".into(), root_turn_id: None, items_view: TurnItemsView::Full, - items: vec![ThreadItem::UserMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: None, content: vec![UserInput::Text { text: "retry me".into(), text_elements: Vec::new(), }], - }], + })], status: TurnStatus::Failed, error: Some(TurnError { misalignment: None, @@ -5107,8 +5342,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!(turns[0].items.len(), 2); assert_eq!( - turns[0].items[1], - ThreadItem::HookPrompt { + turns[0].items[1].payload, + ThreadItemPayload::HookPrompt { id: turns[0].items[1].id().to_string(), fragments: vec![ crate::protocol::v2::HookPromptFragment { @@ -5133,7 +5368,11 @@ mod tests { "hook-run-1", )], }); - let expected_item = ThreadItem::from(hook_prompt.clone()); + let expected_item = ThreadItem { + payload: ThreadItemPayload::from(hook_prompt.clone()), + started_at_ms: Some(10), + completed_at_ms: Some(20), + }; let mut builder = ThreadHistoryBuilder::new(); builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_attribution: None, @@ -5144,18 +5383,18 @@ mod tests { model_context_window: None, collaboration_mode_kind: Default::default(), })); - builder.handle_event(&EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: ThreadId::new(), - turn_id: "turn-a".into(), - item: hook_prompt, - started_at_ms: Some(0), - completed_at_ms: 0, - })); + let changes = + builder.handle_event_with_changes(&EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-a".into(), + item: hook_prompt, + started_at_ms: Some(10), + completed_at_ms: 20, + })); - assert_eq!( - builder.active_turn_snapshot().expect("active turn").items, - vec![expected_item] - ); + let snapshot = builder.active_turn_snapshot().expect("active turn"); + assert_eq!(snapshot.items, [expected_item]); + assert_eq!(changes.changed_items[0].item, snapshot.items[0]); } #[test] @@ -5180,7 +5419,7 @@ mod tests { started_at_ms: Some(1), completed_at_ms: 2, }; - let expected_spawn = ThreadItem::from(spawn.item.clone()); + let expected_spawn = ThreadItemPayload::from(spawn.item.clone()); let legacy_spawn = spawn .as_legacy_events(/*show_raw_agent_reasoning*/ false) .pop() @@ -5191,7 +5430,7 @@ mod tests { panic!("expected item completed"); }; assert_eq!( - (notification.item, notification.completed_at_ms), + (notification.item.payload, notification.completed_at_ms), (expected_spawn.clone(), 2) ); for spawn in [EventMsg::ItemCompleted(spawn), legacy_spawn] { @@ -5236,10 +5475,10 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!( - turns[0].items, - vec![ - expected_spawn.clone(), - ThreadItem::SubAgentActivity { + turns[0].item_payloads().collect::>(), + [ + &expected_spawn, + &ThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "child-turn-completed".into(), @@ -5314,16 +5553,14 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::UserMessage { + item: ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "item-1".into(), client_id: Some("client-message-1".into()), content: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), }], - }, - started_at_ms: None, - completed_at_ms: None, + }) }], changed_turns: vec![ThreadHistoryTurnMetadata { turn_id: "rollout-0".into(), @@ -5364,7 +5601,7 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::WebSearch(WebSearchItem { + item: ThreadItem::untimed(ThreadItemPayload::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { @@ -5372,9 +5609,7 @@ mod tests { queries: None, }), results: None, - }), - started_at_ms: None, - completed_at_ms: None, + })) }], changed_turns: Vec::new(), removed_turn_ids: Vec::new(), @@ -5401,13 +5636,11 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::Reasoning { + item: ThreadItem::untimed(ThreadItemPayload::Reasoning { id: "item-1".into(), summary: vec!["summary".into()], content: vec!["raw content".into()], - }, - started_at_ms: None, - completed_at_ms: None, + }) }], changed_turns: Vec::new(), removed_turn_ids: Vec::new(), @@ -5510,7 +5743,7 @@ mod tests { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "rollout-0".into(), - item: ThreadItem::WebSearch(WebSearchItem { + item: ThreadItem::untimed(ThreadItemPayload::WebSearch(WebSearchItem { id: "search-1".into(), query: "codex".into(), action: Some(WebSearchAction::Search { @@ -5518,9 +5751,7 @@ mod tests { queries: None, }), results: None, - }), - started_at_ms: None, - completed_at_ms: None, + })) }], changed_turns: vec![ThreadHistoryTurnMetadata { turn_id: "rollout-0".into(), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs index 756f42e3ab9d..32d61bf82cce 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs @@ -78,9 +78,7 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet { RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: event.turn_id.clone(), - item: ThreadItem::from(event.item.clone()), - started_at_ms: event.started_at_ms, - completed_at_ms: (event.completed_at_ms != 0).then_some(event.completed_at_ms), + item: ThreadItem::from(event), }], ..Default::default() }, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs index a2ca09c6b74a..9517d459abbd 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs @@ -19,7 +19,7 @@ use pretty_assertions::assert_eq; use std::collections::BTreeMap; use super::*; -use crate::protocol::v2::ThreadItem; +use crate::protocol::v2::ThreadItemPayload; use crate::protocol::v2::TurnError; #[test] @@ -145,18 +145,22 @@ fn projects_completed_canonical_turn_items() { user_changes.changed_items, vec![ThreadHistoryItemChange { turn_id: "turn-1".to_string(), - item: ThreadItem::from(user_item), - started_at_ms: Some(100), - completed_at_ms: Some(123), + item: ThreadItem { + payload: ThreadItemPayload::from(user_item), + started_at_ms: Some(100), + completed_at_ms: Some(123) + } }] ); assert_eq!( agent_changes.changed_items, vec![ThreadHistoryItemChange { turn_id: "turn-1".to_string(), - item: ThreadItem::from(agent_item), - started_at_ms: Some(100), - completed_at_ms: Some(123), + item: ThreadItem { + payload: ThreadItemPayload::from(agent_item), + started_at_ms: Some(100), + completed_at_ms: Some(123) + } }] ); } @@ -187,9 +191,11 @@ fn projects_optional_completed_item_lifecycle_timestamps() { ThreadHistoryChangeSet { changed_items: vec![ThreadHistoryItemChange { turn_id: "turn-1".to_string(), - item: ThreadItem::from(item.clone()), - started_at_ms, - completed_at_ms: expected_completed_at_ms, + item: ThreadItem { + payload: ThreadItemPayload::from(item.clone()), + started_at_ms, + completed_at_ms: expected_completed_at_ms + } }], ..Default::default() } diff --git a/codex-rs/app-server-protocol/src/protocol/turn_items_view.rs b/codex-rs/app-server-protocol/src/protocol/turn_items_view.rs index 7af9fe50ce2a..593a6ca56013 100644 --- a/codex-rs/app-server-protocol/src/protocol/turn_items_view.rs +++ b/codex-rs/app-server-protocol/src/protocol/turn_items_view.rs @@ -1,25 +1,25 @@ //! Projects a turn's items without copying payloads excluded by the requested view. -use super::v2::ThreadItem; +use super::v2::ThreadItemPayload; use super::v2::TurnItemsView; +use std::borrow::Borrow; impl TurnItemsView { /// Copies only the items included in this view, preserving summary selection order. - pub fn project_items(self, items: &[ThreadItem]) -> Vec { + pub fn project_items + Clone>(self, items: &[T]) -> Vec { match self { Self::NotLoaded => Vec::new(), Self::Full => items.to_vec(), Self::Summary => { let first_user_message = items .iter() - .find(|item| matches!(item, ThreadItem::UserMessage { .. })); - let final_agent_message = items - .iter() - .rev() - .find(|item| matches!(item, ThreadItem::AgentMessage { .. })); + .find(|item| matches!((*item).borrow(), ThreadItemPayload::UserMessage { .. })); + let final_agent_message = items.iter().rev().find(|item| { + matches!((*item).borrow(), ThreadItemPayload::AgentMessage { .. }) + }); match (first_user_message, final_agent_message) { (Some(user_message), Some(agent_message)) - if user_message.id() != agent_message.id() => + if user_message.borrow().id() != agent_message.borrow().id() => { vec![user_message.clone(), agent_message.clone()] } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index bde4c2aa87c2..8997b722e207 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -231,11 +231,72 @@ impl CommandAction { } } +/// A thread item and its recorded lifecycle times. The payload stays flat on the wire. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadItem { + #[serde(flatten)] + pub payload: ThreadItemPayload, + /// Unix timestamp in milliseconds when the item started, if recorded. + #[serde(default)] + #[ts(type = "number | null")] + pub started_at_ms: Option, + /// Unix timestamp in milliseconds when the item completed, if recorded. + #[serde(default)] + #[ts(type = "number | null")] + pub completed_at_ms: Option, +} + +impl ThreadItem { + /// Creates an item with no recorded start or completion time. + pub fn untimed(payload: ThreadItemPayload) -> Self { + Self { + payload, + started_at_ms: None, + completed_at_ms: None, + } + } + + fn completed( + payload: ThreadItemPayload, + started_at_ms: Option, + completed_at_ms: i64, + ) -> Self { + Self { + payload, + started_at_ms, + // Older rollout events default a missing completion time to zero. + completed_at_ms: (completed_at_ms != 0).then_some(completed_at_ms), + } + } + + pub fn id(&self) -> &str { + self.payload.id() + } +} + +impl From<&codex_protocol::protocol::ItemCompletedEvent> for ThreadItem { + fn from(event: &codex_protocol::protocol::ItemCompletedEvent) -> Self { + ThreadItem::completed( + ThreadItemPayload::from(event.item.clone()), + event.started_at_ms, + event.completed_at_ms, + ) + } +} + +impl std::borrow::Borrow for ThreadItem { + fn borrow(&self) -> &ThreadItemPayload { + &self.payload + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] #[ts(export_to = "v2/")] -pub enum ThreadItem { +pub enum ThreadItemPayload { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] UserMessage { @@ -453,28 +514,28 @@ pub struct HookPromptFragment { pub hook_run_id: String, } -impl ThreadItem { +impl ThreadItemPayload { pub fn id(&self) -> &str { match self { - ThreadItem::UserMessage { id, .. } - | ThreadItem::HookPrompt { id, .. } - | ThreadItem::AgentMessage { id, .. } - | ThreadItem::FunctionCallOutput { id, .. } - | ThreadItem::Plan { id, .. } - | ThreadItem::Reasoning { id, .. } - | ThreadItem::CommandExecution { id, .. } - | ThreadItem::FileChange { id, .. } - | ThreadItem::McpToolCall { id, .. } - | ThreadItem::DynamicToolCall { id, .. } - | ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::SubAgentActivity { id, .. } - | ThreadItem::ImageView { id, .. } - | ThreadItem::EnteredReviewMode { id, .. } - | ThreadItem::ExitedReviewMode { id, .. } - | ThreadItem::ContextCompaction { id, .. } => id, - ThreadItem::WebSearch(item) => &item.id, - ThreadItem::Sleep(item) => &item.id, - ThreadItem::ImageGeneration(item) => &item.id, + Self::UserMessage { id, .. } + | Self::HookPrompt { id, .. } + | Self::AgentMessage { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::Plan { id, .. } + | Self::Reasoning { id, .. } + | Self::CommandExecution { id, .. } + | Self::FileChange { id, .. } + | Self::McpToolCall { id, .. } + | Self::DynamicToolCall { id, .. } + | Self::CollabAgentToolCall { id, .. } + | Self::SubAgentActivity { id, .. } + | Self::ImageView { id, .. } + | Self::EnteredReviewMode { id, .. } + | Self::ExitedReviewMode { id, .. } + | Self::ContextCompaction { id, .. } => id, + Self::WebSearch(item) => &item.id, + Self::Sleep(item) => &item.id, + Self::ImageGeneration(item) => &item.id, } } } @@ -872,15 +933,15 @@ pub(crate) fn web_search_action_from_core( } } -impl From for ThreadItem { +impl From for ThreadItemPayload { fn from(value: CoreTurnItem) -> Self { match value { - CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { + CoreTurnItem::UserMessage(user) => Self::UserMessage { id: user.id, client_id: user.client_id, content: user.content.into_iter().map(UserInput::from).collect(), }, - CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { + CoreTurnItem::HookPrompt(hook_prompt) => Self::HookPrompt { id: hook_prompt.id, fragments: hook_prompt .fragments @@ -896,7 +957,7 @@ impl From for ThreadItem { CoreAgentMessageContent::Text { text } => text, }) .collect::(); - ThreadItem::AgentMessage { + Self::AgentMessage { id: agent.id, text, phase: agent.phase, @@ -905,17 +966,17 @@ impl From for ThreadItem { questions: agent.questions, } } - CoreTurnItem::FunctionCallOutput(output) => ThreadItem::FunctionCallOutput { + CoreTurnItem::FunctionCallOutput(output) => Self::FunctionCallOutput { id: output.id, name: output.name, namespace: output.namespace, output: output.output, }, - CoreTurnItem::Plan(plan) => ThreadItem::Plan { + CoreTurnItem::Plan(plan) => Self::Plan { id: plan.id, text: plan.text, }, - CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { + CoreTurnItem::Reasoning(reasoning) => Self::Reasoning { id: reasoning.id, summary: reasoning.summary_text, content: reasoning.raw_content, @@ -926,7 +987,7 @@ impl From for ThreadItem { &command.parsed_cmd, &command.cwd, ); - ThreadItem::CommandExecution { + Self::CommandExecution { id: command.id, model_context: command.model_context, sandbox_type: command.sandbox_type, @@ -947,7 +1008,7 @@ impl From for ThreadItem { .and_then(|duration| i64::try_from(duration.as_millis()).ok()), } } - CoreTurnItem::DynamicToolCall(call) => ThreadItem::DynamicToolCall { + CoreTurnItem::DynamicToolCall(call) => Self::DynamicToolCall { id: call.id, namespace: call.namespace, tool: call.tool, @@ -964,7 +1025,7 @@ impl From for ThreadItem { .duration .and_then(|duration| i64::try_from(duration.as_millis()).ok()), }, - CoreTurnItem::CollabAgentToolCall(call) => ThreadItem::CollabAgentToolCall { + CoreTurnItem::CollabAgentToolCall(call) => Self::CollabAgentToolCall { id: call.id, tool: call.tool.into(), status: call.status.into(), @@ -983,7 +1044,7 @@ impl From for ThreadItem { .map(|(thread_id, status)| (thread_id.to_string(), status.into())) .collect(), }, - CoreTurnItem::SubAgentActivity(activity) => ThreadItem::SubAgentActivity { + CoreTurnItem::SubAgentActivity(activity) => Self::SubAgentActivity { model: activity.model, reasoning_effort: activity.reasoning_effort, id: activity.id, @@ -991,46 +1052,44 @@ impl From for ThreadItem { agent_thread_id: activity.agent_thread_id.to_string(), agent_path: String::from(activity.agent_path), }, - CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch(WebSearchItem { + CoreTurnItem::WebSearch(search) => Self::WebSearch(WebSearchItem { id: search.id, query: search.query, action: Some(web_search_action_from_core(search.action)), results: search.results, }), - CoreTurnItem::ImageView(image) => ThreadItem::ImageView { + CoreTurnItem::ImageView(image) => Self::ImageView { id: image.id, path: image.path.into(), }, CoreTurnItem::Extension(extension) => match extension { - ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item), - ExtensionItem::Sleep(item) => ThreadItem::Sleep(item), - ExtensionItem::WebSearch(item) => ThreadItem::WebSearch(item), + ExtensionItem::ImageGeneration(item) => Self::ImageGeneration(item), + ExtensionItem::Sleep(item) => Self::Sleep(item), + ExtensionItem::WebSearch(item) => Self::WebSearch(item), }, - CoreTurnItem::ImageGeneration(image) => { - ThreadItem::ImageGeneration(ImageGenerationItem { - id: image.id, - status: image.status, - revised_prompt: image.revised_prompt, - image: GeneratedImageRef::Inline { - result: image.result, - }, - transparent_background: None, - failure: None, - saved_path: image.saved_path, - input_images: Vec::new(), - imagegen_request_id: None, - generation_id: None, - }) - } - CoreTurnItem::EnteredReviewMode(review) => ThreadItem::EnteredReviewMode { + CoreTurnItem::ImageGeneration(image) => Self::ImageGeneration(ImageGenerationItem { + id: image.id, + status: image.status, + revised_prompt: image.revised_prompt, + image: GeneratedImageRef::Inline { + result: image.result, + }, + transparent_background: None, + failure: None, + saved_path: image.saved_path, + input_images: Vec::new(), + imagegen_request_id: None, + generation_id: None, + }), + CoreTurnItem::EnteredReviewMode(review) => Self::EnteredReviewMode { id: review.id, review: review.user_facing_hint, }, - CoreTurnItem::ExitedReviewMode(review) => ThreadItem::ExitedReviewMode { + CoreTurnItem::ExitedReviewMode(review) => Self::ExitedReviewMode { id: review.id, review: review_output_text(review.review_output.as_ref()), }, - CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { + CoreTurnItem::FileChange(file_change) => Self::FileChange { id: file_change.id, changes: convert_patch_changes(&file_change.changes), status: file_change @@ -1044,7 +1103,7 @@ impl From for ThreadItem { .duration .and_then(|duration| i64::try_from(duration.as_millis()).ok()); - ThreadItem::McpToolCall { + Self::McpToolCall { id: mcp.id, server: mcp.server, tool: mcp.tool, @@ -1067,7 +1126,7 @@ impl From for ThreadItem { } } CoreTurnItem::ContextCompaction(compaction) => { - ThreadItem::ContextCompaction { id: compaction.id } + Self::ContextCompaction { id: compaction.id } } } } @@ -1353,6 +1412,25 @@ pub struct ItemStartedNotification { #[ts(type = "number")] pub started_at_ms: i64, } +impl ItemStartedNotification { + pub fn new( + thread_id: String, + turn_id: String, + payload: ThreadItemPayload, + started_at_ms: i64, + ) -> Self { + Self { + thread_id, + turn_id, + started_at_ms, + item: ThreadItem { + payload, + started_at_ms: Some(started_at_ms), + completed_at_ms: None, + }, + } + } +} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -1431,6 +1509,22 @@ pub struct ItemCompletedNotification { #[ts(type = "number")] pub completed_at_ms: i64, } +impl ItemCompletedNotification { + pub fn new( + thread_id: String, + turn_id: String, + payload: ThreadItemPayload, + started_at_ms: Option, + completed_at_ms: i64, + ) -> Self { + Self { + thread_id, + turn_id, + completed_at_ms, + item: ThreadItem::completed(payload, started_at_ms, completed_at_ms), + } + } +} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index d268de69fb12..bc722c514d0a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -461,8 +461,12 @@ fn thread_items_list_round_trips() { let response = ThreadItemsListResponse { data: vec![ThreadItemEntry { turn_id: "turn_456".to_string(), - item: ThreadItem::ContextCompaction { - id: "item_1".to_string(), + item: ThreadItem { + payload: ThreadItemPayload::ContextCompaction { + id: "item_1".to_string(), + }, + started_at_ms, + completed_at_ms, }, started_at_ms, completed_at_ms, @@ -476,7 +480,7 @@ fn thread_items_list_round_trips() { json!({ "data": [{ "turnId": "turn_456", - "item": {"type": "contextCompaction", "id": "item_1"}, + "item": {"type": "contextCompaction", "id": "item_1", "startedAtMs": started_at_ms, "completedAtMs": completed_at_ms}, "startedAtMs": started_at_ms, "completedAtMs": completed_at_ms, }], @@ -3164,8 +3168,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(user_item), - ThreadItem::UserMessage { + ThreadItemPayload::from(user_item), + ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: Some("client-message-1".to_string()), content: vec![ @@ -3218,8 +3222,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(agent_item), - ThreadItem::AgentMessage { + ThreadItemPayload::from(agent_item), + ThreadItemPayload::AgentMessage { id: "agent-1".to_string(), text: "Hello world".to_string(), phase: None, @@ -3249,8 +3253,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(agent_item_with_phase), - ThreadItem::AgentMessage { + ThreadItemPayload::from(agent_item_with_phase), + ThreadItemPayload::AgentMessage { id: "agent-2".to_string(), text: "final".to_string(), phase: Some(MessagePhase::FinalAnswer), @@ -3268,7 +3272,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { } ); - let async_item = ThreadItem::from(TurnItem::AgentMessage(AgentMessageItem { + let async_item = ThreadItemPayload::from(TurnItem::AgentMessage(AgentMessageItem { id: "async-1".to_string(), content: vec![AgentMessageContent::Text { text: "Which?".to_string(), @@ -3288,13 +3292,13 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { "memoryCitation": null, "delivery": "async", "questions": [{"title": "Which?", "options": null}] }) ); - let old_item: ThreadItem = serde_json::from_value(json!({ + let old_item: ThreadItemPayload = serde_json::from_value(json!({ "type": "agentMessage", "id": "old-1", "text": "An old message" })) .unwrap(); assert!(matches!( old_item, - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { questions: None, .. } @@ -3307,8 +3311,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(reasoning_item), - ThreadItem::Reasoning { + ThreadItemPayload::from(reasoning_item), + ThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: vec!["line one".to_string(), "line two".to_string()], content: vec![], @@ -3345,8 +3349,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(command_item), - ThreadItem::CommandExecution { + ThreadItemPayload::from(command_item), + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "exec-1".to_string(), @@ -3391,8 +3395,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(dynamic_tool_call_item), - ThreadItem::DynamicToolCall { + ThreadItemPayload::from(dynamic_tool_call_item), + ThreadItemPayload::DynamicToolCall { id: "dynamic-1".to_string(), namespace: Some("apps".to_string()), tool: "lookup".to_string(), @@ -3432,8 +3436,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(collab_item), - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::from(collab_item), + ThreadItemPayload::CollabAgentToolCall { id: "collab-1".to_string(), tool: CollabAgentTool::SendInput, status: CollabAgentToolCallStatus::Completed, @@ -3466,8 +3470,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(sub_agent_activity_item), - ThreadItem::SubAgentActivity { + ThreadItemPayload::from(sub_agent_activity_item), + ThreadItemPayload::SubAgentActivity { model: Some("gpt-5".into()), reasoning_effort: Some(ReasoningEffort::High), id: "activity-1".to_string(), @@ -3484,7 +3488,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { "agent_path": "/root/worker", })) .unwrap(); - let old_wire_activity: ThreadItem = serde_json::from_value(json!({ + let old_wire_activity: ThreadItemPayload = serde_json::from_value(json!({ "type": "subAgentActivity", "id": "old-activity", "kind": "started", @@ -3493,7 +3497,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { })) .unwrap(); assert_eq!( - ThreadItem::from(TurnItem::SubAgentActivity(old_activity)), + ThreadItemPayload::from(TurnItem::SubAgentActivity(old_activity)), old_wire_activity ); @@ -3526,14 +3530,14 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }; assert_eq!( - ThreadItem::from(search_item), - ThreadItem::WebSearch(expected_search_item.clone()) + ThreadItemPayload::from(search_item), + ThreadItemPayload::WebSearch(expected_search_item.clone()) ); assert_eq!( - ThreadItem::from(TurnItem::Extension( + ThreadItemPayload::from(TurnItem::Extension( codex_extension_items::ExtensionItem::WebSearch(expected_search_item.clone()), )), - ThreadItem::WebSearch(expected_search_item) + ThreadItemPayload::WebSearch(expected_search_item) ); let image_view_item = TurnItem::ImageView(ImageViewItem { @@ -3542,8 +3546,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(image_view_item), - ThreadItem::ImageView { + ThreadItemPayload::from(image_view_item), + ThreadItemPayload::ImageView { id: "view-image-1".to_string(), path: LegacyAppPathString::from_abs_path(&test_path_buf("/tmp/view-image.png").abs()), } @@ -3566,8 +3570,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(file_change_item), - ThreadItem::FileChange { + ThreadItemPayload::from(file_change_item), + ThreadItemPayload::FileChange { id: "patch-1".to_string(), changes: vec![FileUpdateChange { path: "README.md".to_string(), @@ -3598,8 +3602,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(mcp_tool_call_item), - ThreadItem::McpToolCall { + ThreadItemPayload::from(mcp_tool_call_item), + ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server".to_string(), tool: "tool".to_string(), @@ -3647,8 +3651,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }); assert_eq!( - ThreadItem::from(completed_mcp_tool_call_item), - ThreadItem::McpToolCall { + ThreadItemPayload::from(completed_mcp_tool_call_item), + ThreadItemPayload::McpToolCall { id: "mcp-2".to_string(), server: "server".to_string(), tool: "tool".to_string(), @@ -3672,7 +3676,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { #[test] fn mcp_tool_call_app_context_serializes_connector_id() { - let item = ThreadItem::McpToolCall { + let item = ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "codex_apps".to_string(), tool: "calendar.create_event".to_string(), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 79422eba095e..b31dfdf0936b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1845,6 +1845,16 @@ pub struct ThreadItemEntry { pub completed_at_ms: Option, } +impl From for ThreadItem { + fn from(entry: ThreadItemEntry) -> Self { + Self { + started_at_ms: entry.item.started_at_ms.or(entry.started_at_ms), + completed_at_ms: entry.item.completed_at_ms.or(entry.completed_at_ms), + ..entry.item + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 015d81346207..dd7c0d854a62 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -1,6 +1,7 @@ use super::CodexErrorInfo; use super::ThreadEnvironment; use super::ThreadItem; +use super::ThreadItemPayload; use super::ThreadStatus; use super::TurnStatus; use crate::JsonSchema; @@ -409,6 +410,15 @@ pub struct Turn { pub duration_ms: Option, } +impl Turn { + /// Iterates over the item payloads in this turn. + pub fn item_payloads( + &self, + ) -> impl DoubleEndedIterator + ExactSizeIterator { + self.items.iter().map(|item| &item.payload) + } +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 5db6d881ae7e..ae04387445c6 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -57,7 +57,7 @@ use codex_app_server_protocol::ThreadDecrementElicitationParams; use codex_app_server_protocol::ThreadDecrementElicitationResponse; use codex_app_server_protocol::ThreadIncrementElicitationParams; use codex_app_server_protocol::ThreadIncrementElicitationResponse; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadResumeParams; @@ -1456,13 +1456,13 @@ struct CodexClient { command_item_started: bool, helper_done_seen: bool, turn_completed_before_helper_done: bool, - unexpected_items_before_helper_done: Vec, + unexpected_items_before_helper_done: Vec, last_turn_status: Option, last_turn_error_message: Option, } fn item_started_before_helper_done_is_unexpected( - item: &ThreadItem, + item: &ThreadItemPayload, command_item_started: bool, helper_done_seen: bool, ) -> bool { @@ -1470,7 +1470,7 @@ fn item_started_before_helper_done_is_unexpected( return false; } - !matches!(item, ThreadItem::UserMessage { .. }) + !matches!(item, ThreadItemPayload::UserMessage { .. }) } impl CodexClient { @@ -1816,28 +1816,31 @@ impl CodexClient { std::io::stdout().flush().ok(); } ServerNotification::ItemStarted(payload) => { - if matches!(payload.item, ThreadItem::CommandExecution { .. }) { + if matches!( + payload.item.payload, + ThreadItemPayload::CommandExecution { .. } + ) { if self.command_item_started && !self.helper_done_seen { self.unexpected_items_before_helper_done - .push(payload.item.clone()); + .push(payload.item.payload.clone()); } self.command_item_started = true; } else if item_started_before_helper_done_is_unexpected( - &payload.item, + &payload.item.payload, self.command_item_started, self.helper_done_seen, ) { self.unexpected_items_before_helper_done - .push(payload.item.clone()); + .push(payload.item.payload.clone()); } - println!("\n< item started: {:?}", payload.item); + println!("\n< item started: {:?}", payload.item.payload); } ServerNotification::ItemCompleted(payload) => { - if let ThreadItem::CommandExecution { + if let ThreadItemPayload::CommandExecution { status, aggregated_output, .. - } = payload.item.clone() + } = payload.item.payload.clone() { self.command_execution_statuses.push(status); if let Some(aggregated_output) = aggregated_output { @@ -1845,7 +1848,7 @@ impl CodexClient { self.command_execution_outputs.push(aggregated_output); } } - println!("< item completed: {:?}", payload.item); + println!("< item completed: {:?}", payload.item.payload); } ServerNotification::TurnCompleted(payload) => { if payload.turn.id == turn_id { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 97d91065f466..269e649e7290 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -611,6 +611,11 @@ execution and sandbox policies. # Item history anchors +Live notifications and turn history return the same `ThreadItem`. Each item has +`startedAtMs` and `completedAtMs` beside `type` and `id`. Each timestamp is in +Unix milliseconds, or `null` when the producer has not recorded it. Clients +reading older servers may also encounter items that omit these fields. + `thread/items/list` accepts an optional nullable `cursor`: either an opaque string from a previous response or an item anchor such as `{"type":"item","itemId":"item-123"}`. An item anchor resumes exclusively after diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d0d363e40140..8e5c1b9adebe 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -54,6 +54,7 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::StrictReviewRequiredNotification; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadRealtimeClosedNotification; use codex_app_server_protocol::ThreadRealtimeErrorNotification; use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; @@ -156,6 +157,7 @@ pub(crate) async fn apply_bespoke_event_handling( id: event_turn_id, msg, } = event; + let approval_starts_command = msg.command_approval_start().is_some(); match msg { EventMsg::TurnStarted(payload) => { // While not technically necessary as it was already done on TurnComplete, be extra cautios and abort any pending server requests. @@ -165,7 +167,8 @@ pub(crate) async fn apply_bespoke_event_handling( .await; let turn = { let state = thread_state.lock().await; - let mut turn = state.active_turn_snapshot().unwrap_or_else(|| Turn { + let turn = state.active_turn_metadata_snapshot().map(Turn::from); + turn.unwrap_or_else(|| Turn { id: payload.turn_id.clone(), root_turn_id: payload.root_turn_id.clone(), items: Vec::new(), @@ -175,10 +178,7 @@ pub(crate) async fn apply_bespoke_event_handling( started_at: payload.started_at, completed_at: None, duration_ms: None, - }); - turn.items.clear(); - turn.items_view = TurnItemsView::NotLoaded; - turn + }) }; let notification = TurnStartedNotification { thread_id: conversation_id.to_string(), @@ -300,7 +300,7 @@ pub(crate) async fn apply_bespoke_event_handling( &assessment, CommandExecutionStatus::InProgress, ) { - Some(ThreadItem::CommandExecution { + Some(ThreadItemPayload::CommandExecution { id, model_context, plugin_id, @@ -327,20 +327,15 @@ pub(crate) async fn apply_bespoke_event_handling( } else { assessment.turn_id.clone() }; - if assessment.status == codex_protocol::protocol::GuardianAssessmentStatus::InProgress + if approval_starts_command && let Some((target_item_id, completion_item)) = pending_command_execution.as_ref() { start_command_execution_item( &conversation_id, assessment_turn_id.clone(), target_item_id.clone(), - completion_item.model_context.clone(), - completion_item.plugin_id.clone(), - completion_item.script_path.clone(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, + assessment.started_at_ms, + completion_item, &outgoing, &thread_state, ) @@ -393,9 +388,8 @@ pub(crate) async fn apply_bespoke_event_handling( assessment_turn_id, target_item_id, completion_item, - /*process_id*/ None, - CommandExecutionSource::Agent, completion_status, + assessment.completed_at_ms, &outgoing, &thread_state, ) @@ -760,20 +754,13 @@ pub(crate) async fn apply_bespoke_event_handling( Some(completion_item), ), }; - if approval_id.is_none() - && let Some(completion_item) = completion_item.as_ref() - { + if approval_starts_command && let Some(completion_item) = completion_item.as_ref() { start_command_execution_item( &conversation_id, event_turn_id.clone(), call_id.clone(), - completion_item.model_context.clone(), - completion_item.plugin_id.clone(), - completion_item.script_path.clone(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, + started_at_ms, + completion_item, &outgoing, &thread_state, ) @@ -1088,13 +1075,12 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::ItemStarted(event) => { let should_emit = match &event.item { // Approval and guardian flows can emit the command start notification before core - // emits the canonical item. Reuse the same set to suppress that duplicate. + // emits the canonical item. Keep the first observed start and suppress that duplicate. CoreTurnItem::CommandExecution(item) => thread_state .lock() .await .turn_summary - .command_execution_started - .insert(item.id.clone()), + .record_command_start(item.id.clone(), event.started_at_ms), _ => true, }; let dynamic_tool_call_params = match &event.item { @@ -1129,12 +1115,12 @@ pub(crate) async fn apply_bespoke_event_handling( }); } } - EventMsg::ItemCompleted(event) => { + EventMsg::ItemCompleted(mut event) => { apply_canonical_item_completed_side_effects( &thread_manager, &thread_watch_manager, &thread_state, - &event.item, + &mut event, ) .await; let mut notification = item_event_to_server_notification( @@ -1408,16 +1394,18 @@ async fn apply_canonical_item_completed_side_effects( thread_manager: &Arc, thread_watch_manager: &ThreadWatchManager, thread_state: &Arc>, - item: &CoreTurnItem, + event: &mut codex_protocol::protocol::ItemCompletedEvent, ) { - match item { + match &event.item { CoreTurnItem::CommandExecution(item) => { - thread_state + let observed_start = thread_state .lock() .await .turn_summary - .command_execution_started + .command_start_times .remove(&item.id); + // A client can already be displaying the command's approval-time start. + event.started_at_ms = observed_start.or(event.started_at_ms); } CoreTurnItem::SubAgentActivity(activity) if activity.kind == SubAgentActivityKind::Interrupted => @@ -1450,50 +1438,42 @@ async fn remove_missing_thread_watch( } } -#[allow(clippy::too_many_arguments)] async fn start_command_execution_item( conversation_id: &ThreadId, turn_id: String, item_id: String, - model_context: Option, - plugin_id: Option, - script_path: Option, - command: String, - cwd: LegacyAppPathString, - command_actions: Vec, - source: CommandExecutionSource, + started_at_ms: i64, + details: &CommandExecutionCompletionItem, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) -> bool { - let first_start = { - let mut state = thread_state.lock().await; - state - .turn_summary - .command_execution_started - .insert(item_id.clone()) - }; + let first_start = thread_state + .lock() + .await + .turn_summary + .record_command_start(item_id.clone(), started_at_ms); if first_start { - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), + let notification = ItemStartedNotification::new( + conversation_id.to_string(), turn_id, - started_at_ms: now_unix_timestamp_ms(), - item: ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { id: item_id, - model_context, + model_context: details.model_context.clone(), sandbox_type: None, - plugin_id, - script_path, - command, - cwd, + plugin_id: details.plugin_id.clone(), + script_path: details.script_path.clone(), + command: details.command.clone(), + cwd: details.cwd.clone(), process_id: None, - source, + source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, - command_actions, + command_actions: details.command_actions.clone(), aggregated_output: None, exit_code: None, duration_ms: None, }, - }; + started_at_ms, + ); outgoing .send_server_notification(ServerNotification::ItemStarted(notification)) .await; @@ -1507,23 +1487,22 @@ async fn complete_command_execution_item( turn_id: String, item_id: String, completion_item: CommandExecutionCompletionItem, - process_id: Option, - source: CommandExecutionSource, status: CommandExecutionStatus, + completed_at_ms: Option, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - let should_emit = thread_state + let Some(started_at_ms) = thread_state .lock() .await .turn_summary - .command_execution_started - .remove(&item_id); - if !should_emit { + .command_start_times + .remove(&item_id) + else { return; - } + }; - let item = ThreadItem::CommandExecution { + let item = ThreadItemPayload::CommandExecution { id: item_id, model_context: completion_item.model_context, sandbox_type: None, @@ -1531,20 +1510,22 @@ async fn complete_command_execution_item( script_path: completion_item.script_path, command: completion_item.command, cwd: completion_item.cwd, - process_id, - source, + process_id: None, + source: CommandExecutionSource::Agent, status, command_actions: completion_item.command_actions, aggregated_output: None, exit_code: None, duration_ms: None, }; - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), + let notification = ItemCompletedNotification::new( + conversation_id.to_string(), turn_id, - completed_at_ms: now_unix_timestamp_ms(), item, - }; + Some(started_at_ms), + // Guardian supplies the decision time used on resume. Manual approvals are timed here. + completed_at_ms.unwrap_or_else(now_unix_timestamp_ms), + ); outgoing .send_server_notification(ServerNotification::ItemCompleted(notification)) .await; @@ -2132,8 +2113,8 @@ async fn on_command_execution_request_approval_response( let state = thread_state.lock().await; state .turn_summary - .command_execution_started - .contains(&item_id) + .command_start_times + .contains_key(&item_id) } else { false } @@ -2148,9 +2129,8 @@ async fn on_command_execution_request_approval_response( event_turn_id.clone(), item_id.clone(), completion_item, - /*process_id*/ None, - CommandExecutionSource::Agent, status, + /*completed_at_ms*/ None, &outgoing, &thread_state, ) @@ -2551,13 +2531,8 @@ mod tests { &conversation_id, "turn-1".to_string(), "cmd-1".to_string(), - completion_item.model_context.clone(), - completion_item.plugin_id.clone(), - completion_item.script_path.clone(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, + /*started_at_ms*/ 1_000, + &completion_item, &outgoing, &thread_state, ) @@ -2567,11 +2542,12 @@ mod tests { let msg = recv_broadcast_notification(&mut rx).await?; match msg { ServerNotification::ItemStarted(payload) => { + assert_eq!(payload.item.started_at_ms, Some(1_000)); assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-1"); assert_eq!( - payload.item, - ThreadItem::CommandExecution { + payload.item.payload, + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "cmd-1".to_string(), @@ -2596,13 +2572,8 @@ mod tests { &conversation_id, "turn-1".to_string(), "cmd-1".to_string(), - completion_item.model_context.clone(), - completion_item.plugin_id.clone(), - completion_item.script_path.clone(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, + /*started_at_ms*/ 1_100, + &completion_item, &outgoing, &thread_state, ) @@ -2633,13 +2604,8 @@ mod tests { &conversation_id, "turn-1".to_string(), "cmd-1".to_string(), - completion_item.model_context.clone(), - completion_item.plugin_id.clone(), - completion_item.script_path.clone(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, + /*started_at_ms*/ 1_000, + &completion_item, &outgoing, &thread_state, ) @@ -2651,9 +2617,8 @@ mod tests { "turn-1".to_string(), "cmd-1".to_string(), completion_item, - /*process_id*/ None, - CommandExecutionSource::Agent, CommandExecutionStatus::Declined, + /*completed_at_ms*/ None, &outgoing, &thread_state, ) @@ -2662,13 +2627,13 @@ mod tests { let completed = recv_broadcast_notification(&mut rx).await?; match completed { ServerNotification::ItemCompleted(payload) => { - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, plugin_id, script_path, status, .. - } = payload.item + } = payload.item.payload else { bail!("expected command execution completion"); }; @@ -2685,9 +2650,8 @@ mod tests { "turn-1".to_string(), "cmd-1".to_string(), command_execution_completion_item("printf hi"), - /*process_id*/ None, - CommandExecutionSource::Agent, CommandExecutionStatus::Declined, + /*completed_at_ms*/ None, &outgoing, &thread_state, ) @@ -2750,13 +2714,13 @@ mod tests { match first { ServerNotification::ItemStarted(payload) => { assert_eq!(payload.turn_id, "turn-guardian-approved"); - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, plugin_id, script_path, status, .. - } = payload.item + } = payload.item.payload else { bail!("expected command execution item"); }; @@ -2822,13 +2786,13 @@ mod tests { match fourth { ServerNotification::ItemStarted(payload) => { assert_eq!(payload.turn_id, "turn-guardian-denied"); - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, plugin_id, script_path, status, .. - } = payload.item + } = payload.item.payload else { bail!("expected command execution item"); }; @@ -2878,13 +2842,14 @@ mod tests { let seventh = recv_broadcast_notification(&mut rx).await?; match seventh { ServerNotification::ItemCompleted(payload) => { - let ThreadItem::CommandExecution { + assert_eq!(payload.item.completed_at_ms, Some(1_042)); + let ThreadItemPayload::CommandExecution { id, plugin_id, script_path, status, .. - } = payload.item + } = payload.item.payload else { bail!("expected command execution completion"); }; @@ -3360,8 +3325,10 @@ mod tests { }; assert_eq!( payload, - ItemCompletedNotification { - item: ThreadItem::SubAgentActivity { + ItemCompletedNotification::new( + conversation_id.to_string(), + "turn-1".to_string(), + ThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "activity-1".to_string(), @@ -3369,10 +3336,9 @@ mod tests { agent_thread_id: child_thread_id_string, agent_path: "/root/worker".to_string(), }, - thread_id: conversation_id.to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 42, - } + Some(42), + /*completed_at_ms*/ 42 + ) ); Ok(()) } @@ -3511,8 +3477,8 @@ mod tests { delivery: None, questions: None, }), - started_at_ms: Some(0), - completed_at_ms: 0, + started_at_ms: Some(42_123), + completed_at_ms: 42_200, }), ); state.track_current_turn_event( @@ -3530,8 +3496,8 @@ mod tests { delivery: None, questions: None, }), - started_at_ms: Some(0), - completed_at_ms: 0, + started_at_ms: Some(42_321), + completed_at_ms: 42_400, }), ); state.track_current_turn_event(&event_turn_id, &EventMsg::TurnComplete(event.clone())); @@ -3554,7 +3520,7 @@ mod tests { assert_eq!(n.turn.items_view, TurnItemsView::Summary); assert!(matches!( &n.turn.items[..], - [ThreadItem::AgentMessage { id, text, .. }] + [ThreadItem { payload: ThreadItemPayload::AgentMessage { id, text, .. }, started_at_ms: Some(42_123), completed_at_ms: Some(42_200) }] if id == "msg-1" && text == "complete response" )); assert_eq!(n.turn.error, None); diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index c9576732b02b..ffdc5bc29e83 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -80,7 +80,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_arg0::Arg0DispatchPaths; use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; @@ -120,8 +120,11 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b | ServerNotification::ThreadAttachmentUpdated(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::ItemCompleted(ItemCompletedNotification { - item: ThreadItem::AgentMessage { - delivery: Some(AgentMessageDelivery::Async), + item: codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::AgentMessage { + delivery: Some(AgentMessageDelivery::Async), + .. + }, .. }, .. @@ -1084,8 +1087,10 @@ mod tests { }) )); assert!(server_notification_requires_delivery( - &ServerNotification::ItemCompleted(ItemCompletedNotification { - item: ThreadItem::AgentMessage { + &ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "item-1".to_string(), text: "Still working".to_string(), phase: None, @@ -1093,10 +1098,9 @@ mod tests { delivery: Some(AgentMessageDelivery::Async), questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }) + /*started_at_ms*/ None, + /*completed_at_ms*/ 0 + )) )); } diff --git a/codex-rs/app-server/src/notification_media.rs b/codex-rs/app-server/src/notification_media.rs index 2391a0bc224e..99e4d1e1cb2d 100644 --- a/codex-rs/app-server/src/notification_media.rs +++ b/codex-rs/app-server/src/notification_media.rs @@ -1,7 +1,7 @@ use codex_app_server_protocol::DynamicToolCallOutputContentItem; use codex_app_server_protocol::GeneratedImageRef; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::UserInput; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -11,11 +11,11 @@ use codex_protocol::models::ResponseItem; pub(crate) fn without_notification_media(notification: ServerNotification) -> ServerNotification { match notification { ServerNotification::ItemStarted(mut notification) => { - notification.item = without_thread_item_media(notification.item); + notification.item.payload = without_thread_item_media(notification.item.payload); ServerNotification::ItemStarted(notification) } ServerNotification::ItemCompleted(mut notification) => { - notification.item = without_thread_item_media(notification.item); + notification.item.payload = without_thread_item_media(notification.item.payload); ServerNotification::ItemCompleted(notification) } ServerNotification::RawResponseItemCompleted(mut notification) => { @@ -142,9 +142,9 @@ pub(crate) fn without_notification_media(notification: ServerNotification) -> Se } } -fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem { +fn without_thread_item_media(mut item: ThreadItemPayload) -> ThreadItemPayload { match &mut item { - ThreadItem::UserMessage { content, .. } => { + ThreadItemPayload::UserMessage { content, .. } => { content.retain(|item| match item { UserInput::Image { .. } | UserInput::Audio { .. } => false, UserInput::Text { .. } @@ -154,7 +154,7 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem { | UserInput::Mention { .. } => true, }); } - ThreadItem::DynamicToolCall { + ThreadItemPayload::DynamicToolCall { content_items: Some(content_items), .. } => { @@ -164,7 +164,7 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem { DynamicToolCallOutputContentItem::InputText { .. } => true, }); } - ThreadItem::FunctionCallOutput { + ThreadItemPayload::FunctionCallOutput { output: FunctionCallOutputBody::ContentItems(items), .. } => { @@ -175,7 +175,7 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem { | FunctionCallOutputContentItem::EncryptedContent { .. } => true, }); } - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { result: Some(result), .. } => { @@ -191,34 +191,34 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem { .is_none() }); } - ThreadItem::ImageGeneration(item) => { + ThreadItemPayload::ImageGeneration(item) => { item.image = GeneratedImageRef::Inline { result: String::new(), }; } - ThreadItem::HookPrompt { .. } - | ThreadItem::AgentMessage { .. } - | ThreadItem::Plan { .. } - | ThreadItem::Reasoning { .. } - | ThreadItem::FunctionCallOutput { + ThreadItemPayload::HookPrompt { .. } + | ThreadItemPayload::AgentMessage { .. } + | ThreadItemPayload::Plan { .. } + | ThreadItemPayload::Reasoning { .. } + | ThreadItemPayload::FunctionCallOutput { output: FunctionCallOutputBody::Text(_), .. } - | ThreadItem::CommandExecution { .. } - | ThreadItem::FileChange { .. } - | ThreadItem::McpToolCall { result: None, .. } - | ThreadItem::DynamicToolCall { + | ThreadItemPayload::CommandExecution { .. } + | ThreadItemPayload::FileChange { .. } + | ThreadItemPayload::McpToolCall { result: None, .. } + | ThreadItemPayload::DynamicToolCall { content_items: None, .. } - | ThreadItem::CollabAgentToolCall { .. } - | ThreadItem::SubAgentActivity { .. } - | ThreadItem::WebSearch(_) - | ThreadItem::ImageView { .. } - | ThreadItem::Sleep(_) - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } - | ThreadItem::ContextCompaction { .. } => {} + | ThreadItemPayload::CollabAgentToolCall { .. } + | ThreadItemPayload::SubAgentActivity { .. } + | ThreadItemPayload::WebSearch(_) + | ThreadItemPayload::ImageView { .. } + | ThreadItemPayload::Sleep(_) + | ThreadItemPayload::EnteredReviewMode { .. } + | ThreadItemPayload::ExitedReviewMode { .. } + | ThreadItemPayload::ContextCompaction { .. } => {} } item } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 21c8d2327c4a..3ebcd1d7eea8 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -229,8 +229,8 @@ use codex_app_server_protocol::ThreadIncrementElicitationParams; use codex_app_server_protocol::ThreadIncrementElicitationResponse; use codex_app_server_protocol::ThreadInjectItemsParams; use codex_app_server_protocol::ThreadInjectItemsResponse; -use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadItemEntry; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::ThreadItemsReadParams; diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index f864f0f341ff..fe7602cf2c55 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -271,6 +271,7 @@ pub(super) async fn ensure_listener_task_running( &conversation, watch_registration, thread_settings_baseline, + config_snapshot.history_mode, ); let Some(listener_command_tx) = thread_state.listener_command_tx() else { tracing::warn!( diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 941e32e5c0fd..b8907648bacc 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -19,6 +19,7 @@ use crate::thread_sample::SampleThread; use crate::thread_sample::generate_sample; use codex_app_server_protocol::SelectedCapabilityRoot; use codex_app_server_protocol::ThreadHistoryMode as ApiThreadHistoryMode; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadItemsListAnchor; use codex_app_server_protocol::ThreadItemsListCursor; use codex_app_server_protocol::ThreadRevertParams; @@ -3515,8 +3516,8 @@ impl ThreadRequestProcessor { }) .await .map_err(paginated_history_list_error)?; - for item in page.items { - items.push(deserialize_stored_thread_item(item)?); + for item in &page.items { + items.push(stored_thread_item_to_api_item(item)?); } let Some(next_cursor) = page.next_cursor else { return Ok(items); @@ -6366,29 +6367,31 @@ fn paginated_history_list_error(err: ThreadStoreError) -> JSONRPCErrorError { } } -fn deserialize_stored_thread_item( - item: codex_thread_store::StoredThreadItem, +fn stored_thread_item_to_api_item( + stored_item: &codex_thread_store::StoredThreadItem, ) -> Result { - serde_json::from_slice::(&item.item_json).map_err(|err| { + let payload = serde_json::from_slice(&stored_item.item_json).map_err(|err| { internal_error(format!( "failed to deserialize stored thread item {}: {err}", - item.item_id + stored_item.item_id )) + })?; + Ok(ThreadItem { + payload, + started_at_ms: stored_item.started_at_ms, + completed_at_ms: stored_item.completed_at_ms, }) } fn stored_thread_item_to_entry( stored_item: codex_thread_store::StoredThreadItem, ) -> Result { - let turn_id = stored_item.turn_id.clone(); - let started_at_ms = stored_item.started_at_ms; - let completed_at_ms = stored_item.completed_at_ms; - let item = deserialize_stored_thread_item(stored_item)?; + let item = stored_thread_item_to_api_item(&stored_item)?; Ok(ThreadItemEntry { - turn_id, + turn_id: stored_item.turn_id, + started_at_ms: item.started_at_ms, + completed_at_ms: item.completed_at_ms, item, - started_at_ms, - completed_at_ms, }) } @@ -6410,8 +6413,8 @@ fn stored_turn_to_api_turn( }); let items = turn .items - .into_iter() - .map(deserialize_stored_thread_item) + .iter() + .map(stored_thread_item_to_api_item) .collect::, _>>()?; Ok(Turn { id: turn.turn_id, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 147c1e084d50..e4549bb1d3c8 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -153,6 +153,7 @@ mod thread_processor_behavior_tests { use chrono::Utc; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; @@ -333,14 +334,14 @@ mod thread_processor_behavior_tests { let active_turn = Turn { id: "live-turn".to_string(), root_turn_id: None, - items: vec![ThreadItem::UserMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "live-user-message".to_string(), client_id: None, content: vec![V2UserInput::Text { text: "live".to_string(), text_elements: Vec::new(), }], - }], + })], items_view: TurnItemsView::Full, error: None, status: TurnStatus::InProgress, diff --git a/codex-rs/app-server/src/request_processors/thread_read_state.rs b/codex-rs/app-server/src/request_processors/thread_read_state.rs index a64c31b55e70..e329369001a3 100644 --- a/codex-rs/app-server/src/request_processors/thread_read_state.rs +++ b/codex-rs/app-server/src/request_processors/thread_read_state.rs @@ -112,8 +112,12 @@ pub(super) async fn publish( return None; } let has_error = summary.last_error.is_some(); - let final_text = match &summary.last_agent_message { - Some(ThreadItem::AgentMessage { text, .. }) => Some(text.as_str()), + let final_text = match summary + .last_agent_message + .as_ref() + .map(|message| &message.payload) + { + Some(ThreadItemPayload::AgentMessage { text, .. }) => Some(text.as_str()), _ => None, }; let visible = match &event.msg { diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 9238d91f6712..5d4cd7a6fa30 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -1,7 +1,7 @@ #[cfg(test)] use codex_app_server_protocol::GeneratedImageRef; use codex_app_server_protocol::McpToolCallResult; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use serde_json::Value as JsonValue; @@ -18,8 +18,8 @@ pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) -> pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) { for turn in turns { - turn.items.retain_mut(|item| match item { - ThreadItem::McpToolCall { + turn.items.retain_mut(|entry| match &mut entry.payload { + ThreadItemPayload::McpToolCall { arguments, result, error, @@ -34,7 +34,7 @@ pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) { } true } - ThreadItem::ImageGeneration(_) => false, + ThreadItemPayload::ImageGeneration(_) => false, _ => true, }); } @@ -60,6 +60,7 @@ mod tests { use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; @@ -70,7 +71,7 @@ mod tests { #[test] fn redacts_mcp_success_result_and_removes_image_generation() { let mut thread = test_thread(vec![ - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id: "agent-1".to_string(), text: "kept".to_string(), phase: None, @@ -78,7 +79,7 @@ mod tests { delivery: None, questions: None, }, - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), tool: "lookup".to_string(), @@ -106,7 +107,7 @@ mod tests { error: None, duration_ms: Some(8), }, - ThreadItem::ImageGeneration(ImageGenerationItem { + ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: "ig-1".to_string(), status: "completed".to_string(), revised_prompt: Some("revised".to_string()), @@ -121,13 +122,16 @@ mod tests { generation_id: None, }), ]); + thread.turns[0].items[1].started_at_ms = Some(123); + thread.turns[0].items[2].started_at_ms = Some(456); redact_thread_resume_payloads(&mut thread.turns); assert_eq!(thread.turns[0].items.len(), 2); + assert_eq!(thread.turns[0].items[1].started_at_ms, Some(123)); assert_eq!( - thread.turns[0].items[0], - ThreadItem::AgentMessage { + thread.turns[0].items[0].payload, + ThreadItemPayload::AgentMessage { id: "agent-1".to_string(), text: "kept".to_string(), phase: None, @@ -137,8 +141,8 @@ mod tests { } ); assert_eq!( - thread.turns[0].items[1], - ThreadItem::McpToolCall { + thread.turns[0].items[1].payload, + ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), tool: "lookup".to_string(), @@ -164,7 +168,7 @@ mod tests { #[test] fn redacts_mcp_error_message() { - let mut thread = test_thread(vec![ThreadItem::McpToolCall { + let mut thread = test_thread(vec![ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), tool: "lookup".to_string(), @@ -185,8 +189,8 @@ mod tests { redact_thread_resume_payloads(&mut thread.turns); assert_eq!( - thread.turns[0].items[0], - ThreadItem::McpToolCall { + thread.turns[0].items[0].payload, + ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "docs".to_string(), tool: "lookup".to_string(), @@ -206,7 +210,7 @@ mod tests { ); } - fn test_thread(items: Vec) -> Thread { + fn test_thread(items: Vec) -> Thread { Thread { originator: None, environments: None, @@ -242,7 +246,7 @@ mod tests { turns: vec![Turn { id: "turn-1".to_string(), root_turn_id: None, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), items_view: TurnItemsView::Full, status: TurnStatus::Completed, error: None, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 1ce59c4cf4a0..bc2f029dfaee 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -4,6 +4,7 @@ use codex_agent_extension::AgentInvocation; use codex_agent_extension::AgentRun; use codex_agent_extension::AgentRunner; use codex_app_server_protocol::ImageReference as V2ImageReference; +use codex_app_server_protocol::ThreadItem; use codex_protocol::error::CodexErrorDetails; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -1412,7 +1413,7 @@ impl TurnRequestProcessor { let items = if display_text.is_empty() { Vec::new() } else { - vec![ThreadItem::UserMessage { + vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: turn_id.clone(), client_id: None, content: vec![V2UserInput::Text { @@ -1420,7 +1421,7 @@ impl TurnRequestProcessor { // Review prompt display text is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }], - }] + })] }; Turn { diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 6dfe969ab3ad..5f0bbe5ab8d4 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -19,6 +19,7 @@ use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::MessagePhase; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ThreadHistoryMode; use codex_rollout::RolloutItem; use codex_rollout::state_db::StateDbHandle; use codex_utils_path_uri::LegacyAppPathString; @@ -95,11 +96,24 @@ pub(crate) enum ThreadListenerCommand { #[derive(Default, Clone)] pub(crate) struct TurnSummary { pub(crate) started_at: Option, - pub(crate) command_execution_started: HashSet, + pub(crate) command_start_times: HashMap, pub(crate) last_error: Option, pub(crate) last_agent_message: Option, } +impl TurnSummary { + pub(crate) fn record_command_start(&mut self, item_id: String, started_at_ms: i64) -> bool { + if let std::collections::hash_map::Entry::Vacant(entry) = + self.command_start_times.entry(item_id) + { + entry.insert(started_at_ms); + true + } else { + false + } + } +} + #[derive(Default)] pub(crate) struct ThreadState { goal_resume_lock: Arc>, @@ -115,6 +129,7 @@ pub(crate) struct ThreadState { last_thread_settings: Option, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, + history_mode: ThreadHistoryMode, listener_thread: Option>, watch_registration: WatchRegistration, } @@ -133,12 +148,14 @@ impl ThreadState { conversation: &Arc, watch_registration: WatchRegistration, thread_settings_baseline: ThreadSettings, + history_mode: ThreadHistoryMode, ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } self.listener_generation = self.listener_generation.wrapping_add(1); self.last_thread_settings = Some(thread_settings_baseline); + self.history_mode = history_mode; let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); @@ -209,10 +226,10 @@ impl ThreadState { matches!(content, CoreAgentMessageContent::Text { text } if !text.trim().is_empty()) }) { - self.turn_summary.last_agent_message = - Some(ThreadItem::from(CoreTurnItem::AgentMessage(item.clone()))); + self.turn_summary.last_agent_message = Some(ThreadItem::from(payload)); } - self.current_turn_history.handle_event(event); + self.current_turn_history + .handle_event_for_history_mode(event, self.history_mode); if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) { self.last_terminal_turn_id = Some(event_turn_id.to_string()); if !self.current_turn_history.has_active_turn() { diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 29b2b4be8b62..f72a5beeffe5 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -15,7 +15,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadStartParams; @@ -439,8 +439,8 @@ async fn multi_agent_v2_tools_emit_collaborator_analytics() -> Result<()> { .items; let child_thread_id = items .iter() - .find_map(|item| match item { - ThreadItem::SubAgentActivity { + .find_map(|item| match &item.payload { + ThreadItemPayload::SubAgentActivity { id, agent_thread_id, .. @@ -451,12 +451,14 @@ async fn multi_agent_v2_tools_emit_collaborator_analytics() -> Result<()> { assert!( !items .iter() - .any(|item| matches!(item, ThreadItem::CollabAgentToolCall { .. })) + .any(|item| matches!(&item.payload, ThreadItemPayload::CollabAgentToolCall { .. })) ); let activity_ids = items .iter() - .filter_map(|item| match item { - ThreadItem::SubAgentActivity { id, .. } if id.starts_with("call-") => Some(id.as_str()), + .filter_map(|item| match &item.payload { + ThreadItemPayload::SubAgentActivity { id, .. } if id.starts_with("call-") => { + Some(id.as_str()) + } _ => None, }) .collect::>(); diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 47082d837f64..784e6f929195 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -22,7 +22,7 @@ use codex_app_server_protocol::ThreadCompactStartResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; @@ -427,10 +427,10 @@ async fn auto_compaction_emits_started_and_completed_items(route: CompactionRout let started = wait_for_context_compaction_started(&mut mcp).await?; let completed = wait_for_context_compaction_completed(&mut mcp).await?; - let ThreadItem::ContextCompaction { id: started_id } = started.item else { + let ThreadItemPayload::ContextCompaction { id: started_id } = started.item.payload else { unreachable!("started item should be context compaction"); }; - let ThreadItem::ContextCompaction { id: completed_id } = completed.item else { + let ThreadItemPayload::ContextCompaction { id: completed_id } = completed.item.payload else { unreachable!("completed item should be context compaction"); }; @@ -548,10 +548,10 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - let completed = wait_for_context_compaction_completed(&mut mcp).await?; wait_for_turn_completed(&mut mcp, &started.turn_id).await?; - let ThreadItem::ContextCompaction { id: started_id } = started.item else { + let ThreadItemPayload::ContextCompaction { id: started_id } = started.item.payload else { unreachable!("started item should be context compaction"); }; - let ThreadItem::ContextCompaction { id: completed_id } = completed.item else { + let ThreadItemPayload::ContextCompaction { id: completed_id } = completed.item.payload else { unreachable!("completed item should be context compaction"); }; @@ -723,7 +723,7 @@ async fn wait_for_context_compaction_started( loop { let started: ItemStartedNotification = timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??; - if let ThreadItem::ContextCompaction { .. } = started.item { + if let ThreadItemPayload::ContextCompaction { .. } = started.item.payload { return Ok(started); } } @@ -738,7 +738,7 @@ async fn wait_for_context_compaction_completed( mcp.read_notification("item/completed"), ) .await??; - if let ThreadItem::ContextCompaction { .. } = completed.item { + if let ThreadItemPayload::ContextCompaction { .. } = completed.item.payload { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs index 8a0c8fcf2b48..8db91aad33b2 100644 --- a/codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs @@ -9,7 +9,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; @@ -125,12 +125,12 @@ decision = "prompt" loop { let completed: ItemCompletedNotification = app.read_notification("item/completed").await?; - if let ThreadItem::CommandExecution { + if let ThreadItemPayload::CommandExecution { id, status, exit_code, .. - } = completed.item + } = completed.item.payload && id == call_id { assert_eq!( diff --git a/codex-rs/app-server/tests/suite/v2/daybreak_access.rs b/codex-rs/app-server/tests/suite/v2/daybreak_access.rs index 923212b4c5d3..c61b97385456 100644 --- a/codex-rs/app-server/tests/suite/v2/daybreak_access.rs +++ b/codex-rs/app-server/tests/suite/v2/daybreak_access.rs @@ -9,7 +9,7 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::McpServerToolCallResponse; use codex_app_server_protocol::SelectedCapabilityRoot; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -185,9 +185,9 @@ async fn daybreak_access_respects_plugin_provenance(case: AccessCall) -> Result< .await?; loop { let event: ItemCompletedNotification = app.read_notification("item/completed").await?; - if let ThreadItem::McpToolCall { + if let ThreadItemPayload::McpToolCall { result, plugin_id, .. - } = event.item + } = event.item.payload { assert_eq!(plugin_id.as_deref(), Some("selected-daybreak")); results.push(( diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 38f34330ba38..0875bedc172e 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -20,7 +20,7 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -448,7 +448,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res let started = wait_for_dynamic_tool_started(&mut mcp, call_id).await?; assert_eq!(started.thread_id, thread_id); assert_eq!(started.turn_id, turn_id.clone()); - let ThreadItem::DynamicToolCall { + let ThreadItemPayload::DynamicToolCall { id, namespace, tool, @@ -457,7 +457,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res content_items, success, duration_ms, - } = started.item + } = started.item.payload else { panic!("expected dynamic tool call item"); }; @@ -504,7 +504,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; assert_eq!(completed.thread_id, thread_id); assert_eq!(completed.turn_id, turn_id); - let ThreadItem::DynamicToolCall { + let ThreadItemPayload::DynamicToolCall { id, namespace, tool, @@ -513,7 +513,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res content_items, success, duration_ms, - } = completed.item + } = completed.item.payload else { panic!("expected dynamic tool call item"); }; @@ -735,12 +735,12 @@ async fn dynamic_tool_call_round_trip_handles_content_items() -> Result<()> { let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; assert_eq!(completed.thread_id, params.thread_id); assert_eq!(completed.turn_id, params.turn_id); - let ThreadItem::DynamicToolCall { + let ThreadItemPayload::DynamicToolCall { status, content_items: completed_content_items, success, .. - } = completed.item + } = completed.item.payload else { panic!("expected dynamic tool call item"); }; @@ -830,12 +830,12 @@ async fn dynamic_tool_remote_image_response_becomes_model_visible_error() -> Res let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; assert_eq!(completed.thread_id, params.thread_id); assert_eq!(completed.turn_id, params.turn_id); - let ThreadItem::DynamicToolCall { + let ThreadItemPayload::DynamicToolCall { status, content_items, success, .. - } = completed.item + } = completed.item.payload else { panic!("expected dynamic tool call item"); }; @@ -886,12 +886,12 @@ async fn dynamic_tool_remote_audio_response_becomes_model_visible_error() -> Res let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; assert_eq!(completed.thread_id, params.thread_id); assert_eq!(completed.turn_id, params.turn_id); - let ThreadItem::DynamicToolCall { + let ThreadItemPayload::DynamicToolCall { status, content_items, success, .. - } = completed.item + } = completed.item.payload else { panic!("expected dynamic tool call item"); }; @@ -978,7 +978,8 @@ async fn wait_for_dynamic_tool_started( continue; }; let started: ItemStartedNotification = serde_json::from_value(params)?; - if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) { + if matches!(&started.item.payload, ThreadItemPayload::DynamicToolCall { id, .. } if id == call_id) + { return Ok(started); } } @@ -998,7 +999,8 @@ async fn wait_for_dynamic_tool_completed( continue; }; let completed: ItemCompletedNotification = serde_json::from_value(params)?; - if matches!(&completed.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) { + if matches!(&completed.item.payload, ThreadItemPayload::DynamicToolCall { id, .. } if id == call_id) + { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 555ef3036bdb..e6a8d863b3e8 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -34,7 +34,7 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadReadParams; @@ -645,10 +645,10 @@ source = {:?} let response: ThreadReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.thread.turns.len(), 1); - let imported_items = &response.thread.turns[0].items; + let mut imported_items = response.thread.turns[0].item_payloads(); assert_eq!(imported_items.len(), 3); - match &imported_items[0] { - ThreadItem::UserMessage { content, .. } => assert_eq!( + match imported_items.next() { + Some(ThreadItemPayload::UserMessage { content, .. }) => assert_eq!( content, &vec![UserInput::Text { text: "first request".to_string(), @@ -657,8 +657,8 @@ source = {:?} ), other => panic!("expected user message item, got {other:?}"), } - match &imported_items[1] { - ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "first answer"), + match imported_items.next() { + Some(ThreadItemPayload::AgentMessage { text, .. }) => assert_eq!(text, "first answer"), other => panic!("expected agent message item, got {other:?}"), } @@ -2108,10 +2108,10 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let response: ThreadReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.thread.turns.len(), 2); - let control_items = &response.thread.turns[0].items; + let mut control_items = response.thread.turns[0].item_payloads(); assert_eq!(control_items.len(), 1); - match &control_items[0] { - ThreadItem::UserMessage { content, .. } => { + match control_items.next() { + Some(ThreadItemPayload::UserMessage { content, .. }) => { assert_eq!( content, &vec![UserInput::Text { @@ -2122,10 +2122,10 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { } other => panic!("expected user message item, got {other:?}"), } - let imported_items = &response.thread.turns[1].items; + let mut imported_items = response.thread.turns[1].item_payloads(); assert_eq!(imported_items.len(), 3); - match &imported_items[0] { - ThreadItem::UserMessage { content, .. } => { + match imported_items.next() { + Some(ThreadItemPayload::UserMessage { content, .. }) => { assert_eq!( content, &vec![UserInput::Text { @@ -2137,8 +2137,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { other => panic!("expected user message item, got {other:?}"), } assert_eq!( - imported_items.last(), - Some(&ThreadItem::AgentMessage { + imported_items.next_back(), + Some(&ThreadItemPayload::AgentMessage { id: "item-4".into(), text: "".into(), phase: None, @@ -2183,8 +2183,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let response: ThreadReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.thread.turns.len(), 3); - match &response.thread.turns[2].items[1] { - ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "follow-up answer"), + match response.thread.turns[2].item_payloads().nth(/*n*/ 1) { + Some(ThreadItemPayload::AgentMessage { text, .. }) => assert_eq!(text, "follow-up answer"), other => panic!("expected agent message item, got {other:?}"), } diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_import_sync.rs b/codex-rs/app-server/tests/suite/v2/external_agent_import_sync.rs index a527e45d41e9..f04718b1ecc7 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_import_sync.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_import_sync.rs @@ -14,9 +14,10 @@ use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; use pretty_assertions::assert_eq; @@ -229,16 +230,18 @@ async fn start_app_server(codex_home: &Path) -> Result { fn assert_history(response: &ThreadReadResponse, expected: &[(&str, &str)]) { let mut actual = Vec::new(); let mut markers = 0; - for item in response.thread.turns.iter().flat_map(|turn| &turn.items) { + for item in response.thread.turns.iter().flat_map(Turn::item_payloads) { match item { - ThreadItem::UserMessage { content, .. } => { + ThreadItemPayload::UserMessage { content, .. } => { actual.extend(content.iter().filter_map(|input| match input { UserInput::Text { text, .. } => Some(("user", text.as_str())), _ => None, })); } - ThreadItem::AgentMessage { text, .. } if text == IMPORT_MARKER => markers += 1, - ThreadItem::AgentMessage { text, .. } => actual.push(("assistant", text.as_str())), + ThreadItemPayload::AgentMessage { text, .. } if text == IMPORT_MARKER => markers += 1, + ThreadItemPayload::AgentMessage { text, .. } => { + actual.push(("assistant", text.as_str())) + } _ => {} } } diff --git a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs index eee2b00f97f0..9e1bd0a63cb0 100644 --- a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs +++ b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs @@ -37,7 +37,7 @@ use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; @@ -2650,12 +2650,12 @@ async fn user_approval_skips_async_guardian_without_changing_other_modes() -> Re while tool_results.len() < 3 { let completed: ItemCompletedNotification = timeout(TIMEOUT, app_server.read_notification("item/completed")).await??; - if let ThreadItem::McpToolCall { + if let ThreadItemPayload::McpToolCall { status, result, error, .. - } = completed.item + } = completed.item.payload { tool_results.push((status, result.map(|result| result.content), error)); } diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 4d531a777691..e81f9e7be3d4 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -13,13 +13,14 @@ use codex_app_server_protocol::ImageGenerationInputImage; use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::ImageReference as V2ImageReference; use codex_app_server_protocol::ItemCompletedNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; @@ -161,14 +162,14 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model( ) .await??; - let ThreadItem::ImageGeneration(ImageGenerationItem { + let ThreadItemPayload::ImageGeneration(ImageGenerationItem { status, revised_prompt, image, transparent_background, saved_path: Some(saved_path), .. - }) = completed.item + }) = completed.item.payload else { panic!("expected completed image generation item with saved path"); }; @@ -353,13 +354,13 @@ async fn transparent_image_preserves_output_metadata_and_persisted_history( ) .await??; let thread_id = completed.thread_id.clone(); - let ThreadItem::ImageGeneration(ImageGenerationItem { + let ThreadItemPayload::ImageGeneration(ImageGenerationItem { status, image, transparent_background, input_images, .. - }) = completed.item + }) = completed.item.payload else { panic!("expected completed image-generation item"); }; @@ -390,9 +391,9 @@ async fn transparent_image_preserves_output_metadata_and_persisted_history( let persisted_image = thread .turns .iter() - .flat_map(|turn| turn.items.iter()) + .flat_map(Turn::item_payloads) .find_map(|item| match item { - ThreadItem::ImageGeneration(item) => Some(item), + ThreadItemPayload::ImageGeneration(item) => Some(item), _ => None, }) .context("persisted legacy history should contain the generated image")?; @@ -410,9 +411,9 @@ async fn transparent_image_preserves_output_metadata_and_persisted_history( let resumed_image = thread .turns .iter() - .flat_map(|turn| turn.items.iter()) + .flat_map(Turn::item_payloads) .find_map(|item| match item { - ThreadItem::ImageGeneration(item) => Some(item), + ThreadItemPayload::ImageGeneration(item) => Some(item), _ => None, }) .context("resumed legacy history should contain the generated image")?; @@ -471,7 +472,7 @@ async fn automatic_image_background_preserves_unknown_transparency() -> Result<( mcp.read_stream_until_notification_message("turn/completed"), ) .await??; - let ThreadItem::ImageGeneration(image) = completed.item else { + let ThreadItemPayload::ImageGeneration(image) = completed.item.payload else { panic!("expected completed image-generation item"); }; assert_eq!(image.transparent_background, None); @@ -630,8 +631,8 @@ async fn standalone_image_generation_failure_emits_terminal_item( ) .await??; assert_eq!( - completed.item, - ThreadItem::ImageGeneration(ImageGenerationItem { + completed.item.payload, + ThreadItemPayload::ImageGeneration(ImageGenerationItem { id: call_id.to_string(), status: "failed".to_string(), revised_prompt: Some("paint a blue whale".to_string()), @@ -744,7 +745,7 @@ async fn image_generation_usage_limit_preserves_correlated_failure_metadata() -> ) .await??; let thread_id = completed.thread_id.clone(); - let ThreadItem::ImageGeneration(image) = completed.item else { + let ThreadItemPayload::ImageGeneration(image) = completed.item.payload else { panic!("expected failed image-generation item"); }; assert_eq!(image.status, "failed"); @@ -772,9 +773,9 @@ async fn image_generation_usage_limit_preserves_correlated_failure_metadata() -> let persisted_failure = thread .turns .iter() - .flat_map(|turn| turn.items.iter()) + .flat_map(Turn::item_payloads) .find_map(|item| match item { - ThreadItem::ImageGeneration(item) => item.failure.as_ref(), + ThreadItemPayload::ImageGeneration(item) => item.failure.as_ref(), _ => None, }); assert_eq!( @@ -796,9 +797,9 @@ async fn image_generation_usage_limit_preserves_correlated_failure_metadata() -> let resumed_failure = thread .turns .iter() - .flat_map(|turn| turn.items.iter()) + .flat_map(Turn::item_payloads) .find_map(|item| match item { - ThreadItem::ImageGeneration(item) => item.failure.as_ref(), + ThreadItemPayload::ImageGeneration(item) => item.failure.as_ref(), _ => None, }); assert_eq!( @@ -1157,7 +1158,7 @@ async fn run_image_edit_test( wait_for_image_generation_completed(&mut mcp), ) .await??; - let ThreadItem::ImageGeneration(completed_image) = completed.item else { + let ThreadItemPayload::ImageGeneration(completed_image) = completed.item.payload else { panic!("expected completed image-generation item"); }; timeout( @@ -1240,7 +1241,10 @@ async fn wait_for_image_generation_completed( ) -> Result { loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - if matches!(&completed.item, ThreadItem::ImageGeneration(_)) { + if matches!( + &completed.item.payload, + ThreadItemPayload::ImageGeneration(_) + ) { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index f95a709820c5..847bfbe8d3ae 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -29,13 +29,14 @@ use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SelectedCapabilityRoot; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -388,10 +389,10 @@ async fn model_mcp_tool_call_uses_session_client_extensions() -> Result<()> { .await?; let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?; - let ThreadItem::McpToolCall { + let ThreadItemPayload::McpToolCall { result: Some(result), .. - } = completed.item + } = completed.item.payload else { panic!("expected completed MCP tool call item"); }; @@ -1053,7 +1054,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result() let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?; assert_eq!(completed.turn_id, turn.id); - let ThreadItem::McpToolCall { + let ThreadItemPayload::McpToolCall { id, server, tool, @@ -1061,7 +1062,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result() result: Some(result), error, .. - } = completed.item + } = completed.item.payload else { panic!("expected completed MCP tool call item"); }; @@ -1081,7 +1082,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result() assert!(text.contains("truncated")); assert!(text.len() < DEFAULT_OUTPUT_BYTES_CAP + 1024); - let serialized_item = serde_json::to_string(&ThreadItem::McpToolCall { + let serialized_item = serde_json::to_string(&ThreadItemPayload::McpToolCall { id, server, tool, @@ -1193,7 +1194,7 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; - let expected_item = ThreadItem::McpToolCall { + let expected_item = ThreadItemPayload::McpToolCall { id: call_id.to_string(), server: TEST_SERVER_NAME.to_string(), tool: TEST_TOOL_NAME.to_string(), @@ -1211,8 +1212,8 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result let resumed_item = resumed_thread .turns .iter() - .flat_map(|turn| &turn.items) - .find(|item| matches!(item, ThreadItem::McpToolCall { id, .. } if id == call_id)) + .flat_map(Turn::item_payloads) + .find(|item| matches!(item, ThreadItemPayload::McpToolCall { id, .. } if id == call_id)) .expect("resumed thread should include the in-progress MCP tool call"); assert_eq!(resumed_item, &expected_item); @@ -1228,11 +1229,11 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?; assert_eq!(completed.turn_id, turn.id); - let ThreadItem::McpToolCall { + let ThreadItemPayload::McpToolCall { status, read_only_hint, .. - } = &completed.item + } = &completed.item.payload else { panic!("expected the completed MCP tool call item"); }; @@ -1258,10 +1259,10 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result let persisted_item = completed_read .turns .iter() - .flat_map(|turn| &turn.items) - .find(|item| matches!(item, ThreadItem::McpToolCall { id, .. } if id == call_id)) + .flat_map(Turn::item_payloads) + .find(|item| matches!(item, ThreadItemPayload::McpToolCall { id, .. } if id == call_id)) .expect("completed thread history should include the persisted MCP tool call"); - assert_eq!(persisted_item, &completed.item); + assert_eq!(persisted_item, &completed.item.payload); mcp_server_handle.abort(); let _ = mcp_server_handle.await; @@ -1608,7 +1609,8 @@ async fn wait_for_mcp_tool_call_completed( mcp.read_notification("item/completed"), ) .await??; - if matches!(&completed.item, ThreadItem::McpToolCall { id, .. } if id == call_id) { + if matches!(&completed.item.payload, ThreadItemPayload::McpToolCall { id, .. } if id == call_id) + { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index 9709f2cb00b5..bb2cb6578036 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -9,7 +9,7 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::PlanDeltaNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; @@ -63,7 +63,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { assert_eq!(turn_completed.turn.id, turn.id); assert_eq!(turn_completed.turn.status, TurnStatus::Completed); - let expected_plan = ThreadItem::Plan { + let expected_plan = ThreadItemPayload::Plan { id: format!("{}-plan", turn.id), text: "# Final plan\n- first\n- second\n".to_string(), }; @@ -81,7 +81,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { let plan_items = completed_items .iter() .filter_map(|item| match item { - ThreadItem::Plan { .. } => Some(item.clone()), + ThreadItemPayload::Plan { .. } => Some(item.clone()), _ => None, }) .collect::>(); @@ -89,7 +89,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { assert!( completed_items .iter() - .any(|item| matches!(item, ThreadItem::AgentMessage { .. })), + .any(|item| matches!(item, ThreadItemPayload::AgentMessage { .. })), "agent message items should still be emitted alongside the plan item" ); @@ -123,7 +123,7 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> let has_plan_item = completed_items .iter() - .any(|item| matches!(item, ThreadItem::Plan { .. })); + .any(|item| matches!(item, ThreadItemPayload::Plan { .. })); assert!(!has_plan_item); assert!(plan_deltas.is_empty()); @@ -168,8 +168,8 @@ async fn start_plan_mode_turn(mcp: &mut TestAppServer) -> Result Result<( - Vec, - Vec, + Vec, + Vec, Vec, TurnCompletedNotification, )> { @@ -188,14 +188,14 @@ async fn collect_turn_notifications( .params .ok_or_else(|| anyhow!("item/started notifications must include params"))?; let payload: ItemStartedNotification = serde_json::from_value(params)?; - started_items.push(payload.item); + started_items.push(payload.item.payload); } "item/completed" => { let params = notification .params .ok_or_else(|| anyhow!("item/completed notifications must include params"))?; let payload: ItemCompletedNotification = serde_json::from_value(params)?; - completed_items.push(payload.item); + completed_items.push(payload.item.payload); } "item/plan/delta" => { let params = notification diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 8c3927d1e2c6..6c33d9e11acb 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -14,7 +14,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::ThreadReadParams; @@ -56,6 +56,7 @@ use codex_app_server_protocol::ThreadTimelineListParams; use codex_app_server_protocol::ThreadTimelineListResponse; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -1285,8 +1286,8 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa .read_notification::("item/completed") .await?; assert!(matches!( - initial_message.item, - ThreadItem::UserMessage { .. } + initial_message.item.payload, + ThreadItemPayload::UserMessage { .. } )); harness @@ -1397,8 +1398,8 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa item, .. } if matches!( - item.as_ref(), - ThreadItem::UserMessage { client_id, .. } + &item.payload, + ThreadItemPayload::UserMessage { client_id, .. } if client_id.as_deref() == Some("accepted-steer") ) ) @@ -1411,7 +1412,7 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa .iter() .filter_map(|entry| match entry { ThreadTimelineEntry::Item { item, .. } - if matches!(item.as_ref(), ThreadItem::AgentMessage { id, .. } if id == "promoted-message") => Some("artifact"), + if matches!(&item.payload, ThreadItemPayload::AgentMessage { id, .. } if id == "promoted-message") => Some("artifact"), ThreadTimelineEntry::Realtime { item: ThreadRealtimeItem { content: ThreadRealtimeItemContent::BemItemPromoted { item_id, .. }, @@ -1883,7 +1884,7 @@ async fn realtime_conversation_stop_emits_closed_notification( let items: ThreadItemsListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request)).await??; let tails = items.data.iter().filter(|entry| matches!( - &entry.item, ThreadItem::UserMessage { content, .. } + &entry.item.payload, ThreadItemPayload::UserMessage { content, .. } if content.iter().any(|part| matches!(part, V2UserInput::Text { text, .. } if text.contains("transcript_tail_flush") && text.contains("user: final words"))) )).collect::>(); @@ -1913,8 +1914,8 @@ async fn realtime_conversation_stop_emits_closed_notification( .await?; let history: ThreadReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request)).await??; - assert_eq!(history.thread.turns.iter().flat_map(|turn| &turn.items).filter(|item| matches!( - item, ThreadItem::UserMessage { content, .. } + assert_eq!(history.thread.turns.iter().flat_map(Turn::item_payloads).filter(|item| matches!( + item, ThreadItemPayload::UserMessage { content, .. } if content.iter().any(|part| matches!(part, V2UserInput::Text { text, .. } if text.contains("transcript_tail_flush") && text.contains("user: final words"))) )).count(), 1); @@ -3963,7 +3964,8 @@ async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Resul // Phase 2: observe the delegated background agent turn executing the requested shell command. let started_command = wait_for_started_command_execution(&mut harness.mcp).await?; - let ThreadItem::CommandExecution { id, status, .. } = started_command.item else { + let ThreadItemPayload::CommandExecution { id, status, .. } = started_command.item.payload + else { unreachable!("helper returns command execution items"); }; assert_eq!( @@ -3972,12 +3974,12 @@ async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Resul ); let completed_command = wait_for_completed_command_execution(&mut harness.mcp).await?; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, status, aggregated_output, .. - } = completed_command.item + } = completed_command.item.payload else { unreachable!("helper returns command execution items"); }; @@ -4298,7 +4300,7 @@ async fn wait_for_started_command_execution( ) -> Result { loop { let started = read_notification::(mcp, "item/started").await?; - if let ThreadItem::CommandExecution { .. } = &started.item { + if let ThreadItemPayload::CommandExecution { .. } = &started.item.payload { return Ok(started); } } @@ -4310,7 +4312,7 @@ async fn wait_for_completed_command_execution( loop { let completed = read_notification::(mcp, "item/completed").await?; - if let ThreadItem::CommandExecution { .. } = &completed.item { + if let ThreadItemPayload::CommandExecution { .. } = &completed.item.payload { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 077590aa9c9e..07fe60c2ec9a 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -148,14 +149,14 @@ async fn review_start_runs_review_turn_and_emits_code_review_item( assert_eq!(turn.items_view, TurnItemsView::NotLoaded); assert_eq!( turn.items, - vec![ThreadItem::UserMessage { + [ThreadItem::untimed(ThreadItemPayload::UserMessage { id: turn_id.clone(), client_id: None, content: vec![V2UserInput::Text { text: "commit 1234567: Tidy UI colors".to_string(), text_elements: Vec::new(), }], - }] + })] ); // Confirm we see the EnteredReviewMode marker on the main thread. @@ -163,8 +164,8 @@ async fn review_start_runs_review_turn_and_emits_code_review_item( for _ in 0..10 { let started: ItemStartedNotification = timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??; - match started.item { - ThreadItem::EnteredReviewMode { review, .. } => { + match started.item.payload { + ThreadItemPayload::EnteredReviewMode { review, .. } => { assert_eq!(started.turn_id, turn_id); assert_eq!(review, "commit 1234567: Tidy UI colors"); saw_entered_review_mode = true; @@ -187,8 +188,8 @@ async fn review_start_runs_review_turn_and_emits_code_review_item( mcp.read_notification("item/completed"), ) .await??; - match completed.item { - ThreadItem::ExitedReviewMode { review, .. } => { + match completed.item.payload { + ThreadItemPayload::ExitedReviewMode { review, .. } => { assert_eq!(completed.turn_id, turn_id); review_body = Some(review); break; @@ -257,14 +258,14 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> assert_eq!(turn.items_view, TurnItemsView::NotLoaded); assert_eq!( turn.items, - vec![ThreadItem::UserMessage { + [ThreadItem::untimed(ThreadItemPayload::UserMessage { id: turn_id.clone(), client_id: None, content: vec![V2UserInput::Text { text: "commit 1234567: Check review approvals".to_string(), text_elements: Vec::new(), }], - }] + })] ); let server_req = timeout( @@ -282,7 +283,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> for _ in 0..10 { let started: ItemStartedNotification = timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??; - if let ThreadItem::CommandExecution { id, .. } = started.item { + if let ThreadItemPayload::CommandExecution { id, .. } = started.item.payload { command_item_id = Some(id); break; } @@ -437,14 +438,14 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( assert_eq!(turn.items_view, TurnItemsView::NotLoaded); assert_eq!( turn.items, - vec![ThreadItem::UserMessage { + [ThreadItem::untimed(ThreadItemPayload::UserMessage { id: turn.id.clone(), client_id: None, content: vec![V2UserInput::Text { text: expected_prompt.clone(), text_elements: Vec::new(), }], - }] + })] ); assert_ne!( review_thread_id, thread_id, diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 0ac5cdb2b7c1..b1f1d1955ae1 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -11,7 +11,7 @@ use codex_app_server_protocol::ModelRerouteReason; use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::ModelVerification; use codex_app_server_protocol::ModelVerificationNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnModerationMetadataNotification; @@ -365,14 +365,14 @@ async fn collect_turn_notifications_and_validate_no_warning_item( anyhow::anyhow!("item/started notifications must include params") })?; let payload: ItemStartedNotification = serde_json::from_value(params)?; - assert!(!is_warning_user_message_item(&payload.item)); + assert!(!is_warning_user_message_item(&payload.item.payload)); } "item/completed" => { let params = notification.params.ok_or_else(|| { anyhow::anyhow!("item/completed notifications must include params") })?; let payload: ItemCompletedNotification = serde_json::from_value(params)?; - assert!(!is_warning_user_message_item(&payload.item)); + assert!(!is_warning_user_message_item(&payload.item.payload)); } "turn/completed" => { return rerouted.ok_or_else(|| { @@ -413,14 +413,14 @@ async fn collect_model_verification_notifications_and_validate_no_warning_item( anyhow::anyhow!("item/started notifications must include params") })?; let payload: ItemStartedNotification = serde_json::from_value(params)?; - assert!(!is_warning_user_message_item(&payload.item)); + assert!(!is_warning_user_message_item(&payload.item.payload)); } "item/completed" => { let params = notification.params.ok_or_else(|| { anyhow::anyhow!("item/completed notifications must include params") })?; let payload: ItemCompletedNotification = serde_json::from_value(params)?; - assert!(!is_warning_user_message_item(&payload.item)); + assert!(!is_warning_user_message_item(&payload.item.payload)); } "turn/completed" => { let verification = verification.ok_or_else(|| { @@ -468,8 +468,8 @@ async fn collect_cyber_policy_error_and_validate_no_reroute( } } -fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { - let ThreadItem::UserMessage { content, .. } = item else { +fn warning_text_from_item(item: &ThreadItemPayload) -> Option<&str> { + let ThreadItemPayload::UserMessage { content, .. } = item else { return None; }; @@ -479,7 +479,7 @@ fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { }) } -fn is_warning_user_message_item(item: &ThreadItem) -> bool { +fn is_warning_user_message_item(item: &ThreadItemPayload) -> bool { warning_text_from_item(item).is_some() } diff --git a/codex-rs/app-server/tests/suite/v2/sleep.rs b/codex-rs/app-server/tests/suite/v2/sleep.rs index 2b60c6a783f9..b515dbce0811 100644 --- a/codex-rs/app-server/tests/suite/v2/sleep.rs +++ b/codex-rs/app-server/tests/suite/v2/sleep.rs @@ -8,7 +8,7 @@ use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SleepItem; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; @@ -286,28 +286,29 @@ clock_source = "external" ); } - let expected_item = ThreadItem::Sleep(SleepItem { + let expected_item = ThreadItemPayload::Sleep(SleepItem { id: CALL_ID.to_string(), duration_ms: DURATION_MS, }); assert!(completed.completed_at_ms >= started.started_at_ms); assert_eq!( started, - ItemStartedNotification { - item: expected_item.clone(), - thread_id: thread.id.clone(), - turn_id: turn.id.clone(), - started_at_ms: started.started_at_ms, - } + ItemStartedNotification::new( + thread.id.clone(), + turn.id.clone(), + expected_item.clone(), + started.started_at_ms + ) ); assert_eq!( completed, - ItemCompletedNotification { - item: expected_item, - thread_id: thread.id, - turn_id: turn.id, - completed_at_ms: completed.completed_at_ms, - } + ItemCompletedNotification::new( + thread.id, + turn.id, + expected_item, + Some(started.started_at_ms), + completed.completed_at_ms + ) ); Ok(()) @@ -320,7 +321,7 @@ async fn wait_for_sleep_started( loop { let started: ItemStartedNotification = timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??; - if matches!(&started.item, ThreadItem::Sleep(item) if item.id == call_id) { + if matches!(&started.item.payload, ThreadItemPayload::Sleep(item) if item.id == call_id) { return Ok(started); } } @@ -336,7 +337,7 @@ async fn wait_for_sleep_completed( mcp.read_notification("item/completed"), ) .await??; - if matches!(&completed.item, ThreadItem::Sleep(item) if item.id == call_id) { + if matches!(&completed.item.payload, ThreadItemPayload::Sleep(item) if item.id == call_id) { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index c7eabe111f61..11678f0961da 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -37,7 +37,7 @@ use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadReadParams; @@ -250,8 +250,8 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { let turn = &thread.turns[0]; assert_eq!(turn.status, TurnStatus::Interrupted); assert_eq!(turn.items.len(), 1, "expected user message item"); - match &turn.items[0] { - ThreadItem::UserMessage { content, .. } => { + match &turn.items[0].payload { + ThreadItemPayload::UserMessage { content, .. } => { assert_eq!( content, &vec![UserInput::Text { @@ -2102,8 +2102,8 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( assert_eq!(turns[0].status, TurnStatus::Interrupted); assert_eq!(turns[0].items.len(), 1); assert!(matches!( - &turns[0].items[0], - ThreadItem::UserMessage { id, .. } if id == "before-fork" + &turns[0].items[0].payload, + ThreadItemPayload::UserMessage { id, .. } if id == "before-fork" )); let search: ThreadSearchOccurrencesResponse = mcp @@ -2486,8 +2486,8 @@ async fn assert_thread_fork_ephemeral_remains_pathless_and_omits_listing( let turn = &thread.turns[0]; assert_eq!(turn.status, TurnStatus::Completed); assert_eq!(turn.items.len(), 1, "expected user message item"); - match &turn.items[0] { - ThreadItem::UserMessage { content, .. } => { + match &turn.items[0].payload { + ThreadItemPayload::UserMessage { content, .. } => { assert_eq!( content, &vec![UserInput::Text { diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index f2720fedf1af..70aa592be2c8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadInjectItemsParams; use codex_app_server_protocol::ThreadInjectItemsResponse; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; @@ -330,10 +331,16 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history( assert_eq!(turn.id, turn_started.turn.id); assert_eq!(turn_started.turn.status, TurnStatus::InProgress); assert_eq!(item_completed.turn_id, turn_started.turn.id); - assert_eq!(item_started.item, item_completed.item); + assert_eq!( + item_completed.item, + ThreadItem { + completed_at_ms: Some(item_completed.completed_at_ms), + ..item_started.item + } + ); assert!(matches!( - &item_completed.item, - ThreadItem::FunctionCallOutput { + &item_completed.item.payload, + ThreadItemPayload::FunctionCallOutput { name, namespace, output: FunctionCallOutputBody::Text(output), diff --git a/codex-rs/app-server/tests/suite/v2/thread_queue.rs b/codex-rs/app-server/tests/suite/v2/thread_queue.rs index a27c3b5a1ff2..58ec3a90dadb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_queue.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_queue.rs @@ -18,7 +18,7 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::QueuedSubmission; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; @@ -386,9 +386,9 @@ async fn idle_queue_dispatch_preserves_client_id() -> Result<()> { ); let started: ItemStartedNotification = timeout(READ_TIMEOUT, app.read_notification("item/started")).await??; - let ThreadItem::UserMessage { + let ThreadItemPayload::UserMessage { client_id, content, .. - } = started.item + } = started.item.payload else { anyhow::bail!("queued turn did not begin with its user message"); }; @@ -538,9 +538,9 @@ async fn cold_thread_resume_dispatches_a_persisted_queued_submission() -> Result let started: ItemStartedNotification = timeout(READ_TIMEOUT, resumed.read_notification("item/started")).await??; - let ThreadItem::UserMessage { + let ThreadItemPayload::UserMessage { client_id, content, .. - } = started.item + } = started.item.payload else { anyhow::bail!("cold resume did not start the persisted queued user message"); }; @@ -747,14 +747,14 @@ async fn queue_start_while_active_returns_busy_and_preserves_the_queue() -> Resu let started_item: ItemStartedNotification = timeout(READ_TIMEOUT, app.read_notification("item/started")).await??; if matches!( - &started_item.item, - ThreadItem::UserMessage { client_id, .. } + &started_item.item.payload, + ThreadItemPayload::UserMessage { client_id, .. } if client_id.as_deref() == Some("active-queued-client-id") ) { break started_item; } }; - let ThreadItem::UserMessage { client_id, .. } = started_item.item else { + let ThreadItemPayload::UserMessage { client_id, .. } = started_item.item.payload else { anyhow::bail!("queued message did not start after the active turn completed"); }; assert_eq!(client_id.as_deref(), Some("active-queued-client-id")); @@ -823,8 +823,8 @@ async fn queue_start_without_id_starts_the_head_when_idle() -> Result<()> { let started_item: ItemStartedNotification = timeout(READ_TIMEOUT, app.read_notification("item/started")).await??; if matches!( - &started_item.item, - ThreadItem::UserMessage { client_id, .. } + &started_item.item.payload, + ThreadItemPayload::UserMessage { client_id, .. } if client_id.as_deref() == Some(first.client_user_message_id.as_str()) ) { break started_item; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index f1c704e90505..85acd3b31987 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -25,8 +25,8 @@ use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadItemEntry; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListAnchor; use codex_app_server_protocol::ThreadItemsListCursor; use codex_app_server_protocol::ThreadItemsListParams; @@ -222,8 +222,8 @@ async fn thread_read_can_include_turns() -> Result<()> { assert_eq!(turn.root_turn_id, None); assert_eq!(turn.items_view, TurnItemsView::Full); assert_eq!(turn.items.len(), 1, "expected user message item"); - match &turn.items[0] { - ThreadItem::UserMessage { content, .. } => { + match &turn.items[0].payload { + ThreadItemPayload::UserMessage { content, .. } => { assert_eq!( content, &vec![UserInput::Text { @@ -297,7 +297,8 @@ async fn thread_read_preserves_file_id_and_root_from_completed_turn( thread: loaded_thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; - let ThreadItem::UserMessage { content, .. } = &loaded_thread.turns[0].items[0] else { + let ThreadItemPayload::UserMessage { content, .. } = &loaded_thread.turns[0].items[0].payload + else { panic!("expected completed turn to start with a user message"); }; assert_eq!(content, &vec![image.clone()]); @@ -323,7 +324,9 @@ async fn thread_read_preserves_file_id_and_root_from_completed_turn( thread: restarted_thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; - let ThreadItem::UserMessage { content, .. } = &restarted_thread.turns[0].items[0] else { + let ThreadItemPayload::UserMessage { content, .. } = + &restarted_thread.turns[0].items[0].payload + else { panic!("expected persisted turn to start with a user message"); }; assert_eq!(content, &vec![image]); @@ -500,6 +503,7 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { data, next_cursor, backwards_cursor, + .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(turn_user_texts(&data), vec!["third", "second"]); assert!( @@ -1062,7 +1066,13 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa let ThreadReadResponse { thread, .. } = serde_json::from_value(result)?; assert_eq!(turn_user_texts(&thread.turns), vec!["history from store"]); - let [ThreadItem::UserMessage { content, .. }] = thread.turns[0].items.as_slice() else { + let [ + codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::UserMessage { content, .. }, + .. + }, + ] = thread.turns[0].items.as_slice() + else { panic!("expected one user message item"); }; assert_eq!( @@ -1834,17 +1844,17 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( id: "turn-1".to_string(), root_turn_id: None, items: vec![ - ThreadItem::UserMessage { + ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: Vec::new(), }, - ThreadItem::UserMessage { + ThreadItemPayload::UserMessage { id: "steer-1".to_string(), client_id: Some("updated-steer".to_string()), content: Vec::new(), }, - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id: "agent-1".to_string(), text: "first".to_string(), phase: None, @@ -1852,7 +1862,14 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( delivery: None, questions: None, }, - ], + ] + .into_iter() + .map(|payload| codex_app_server_protocol::ThreadItem { + payload, + started_at_ms: Some(0), + completed_at_ms: Some(1), + }) + .collect(), items_view: TurnItemsView::Full, status: TurnStatus::Completed, error: None, @@ -1863,11 +1880,18 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( let expected_turn_2_full = Turn { id: "turn-2".to_string(), root_turn_id: None, - items: vec![ThreadItem::UserMessage { + items: vec![ThreadItemPayload::UserMessage { id: "user-2".to_string(), client_id: None, content: Vec::new(), - }], + }] + .into_iter() + .map(|payload| codex_app_server_protocol::ThreadItem { + payload, + started_at_ms: Some(0), + completed_at_ms: Some(1), + }) + .collect(), items_view: TurnItemsView::Full, status: TurnStatus::Interrupted, error: None, @@ -2029,12 +2053,12 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( id: "turn-1".to_string(), root_turn_id: None, items: vec![ - ThreadItem::UserMessage { + ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: Vec::new(), }, - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id: "agent-1".to_string(), text: "first".to_string(), phase: None, @@ -2042,7 +2066,14 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( delivery: None, questions: None, }, - ], + ] + .into_iter() + .map(|payload| codex_app_server_protocol::ThreadItem { + payload, + started_at_ms: Some(0), + completed_at_ms: Some(1) + }) + .collect(), items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, @@ -2051,6 +2082,24 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( duration_ms: Some(10_000), }] ); + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.to_string(), + exclude_turns: true, + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: Some(1), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::Summary), + }), + ..Default::default() + }) + .await?; + let resumed: ThreadResumeResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; + assert_eq!( + resumed.initial_turns_page.expect("summary page").data, + first_page.data + ); let next_cursor = first_page.next_cursor.expect("next turn cursor"); let second_page = read_turns_page( &mut mcp, @@ -2100,10 +2149,14 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( first_items_page.data, vec![ThreadItemEntry { turn_id: "turn-1".to_string(), - item: ThreadItem::UserMessage { - id: "user-1".to_string(), - client_id: None, - content: Vec::new(), + item: codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::UserMessage { + id: "user-1".to_string(), + client_id: None, + content: Vec::new(), + }, + started_at_ms: Some(0), + completed_at_ms: Some(1) }, started_at_ms: Some(0), completed_at_ms: Some(1), @@ -2232,23 +2285,31 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( vec![ ThreadItemEntry { turn_id: "turn-1".to_string(), - item: ThreadItem::UserMessage { - id: "steer-1".to_string(), - client_id: Some("updated-steer".to_string()), - content: Vec::new(), + item: codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::UserMessage { + id: "steer-1".to_string(), + client_id: Some("updated-steer".to_string()), + content: Vec::new(), + }, + started_at_ms: Some(0), + completed_at_ms: Some(1) }, started_at_ms: Some(0), completed_at_ms: Some(1), }, ThreadItemEntry { turn_id: "turn-1".to_string(), - item: ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "first".to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, + item: codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::AgentMessage { + id: "agent-1".to_string(), + text: "first".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + started_at_ms: Some(0), + completed_at_ms: Some(1) }, started_at_ms: Some(0), completed_at_ms: Some(1), @@ -2607,8 +2668,8 @@ fn paginated_completed_item( fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { turns .iter() - .filter_map(|turn| match turn.items.first()? { - ThreadItem::UserMessage { content, .. } => match content.first()? { + .filter_map(|turn| match &turn.items.first()?.payload { + ThreadItemPayload::UserMessage { content, .. } => match content.first()? { UserInput::Text { text, .. } => Some(text.as_str()), UserInput::Image { .. } | UserInput::LocalImage { .. } @@ -2625,9 +2686,9 @@ fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { fn turn_agent_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { turns .iter() - .flat_map(|turn| &turn.items) + .flat_map(Turn::item_payloads) .filter_map(|item| match item { - ThreadItem::AgentMessage { text, .. } => Some(text.as_str()), + ThreadItemPayload::AgentMessage { text, .. } => Some(text.as_str()), _ => None, }) .collect() diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 19cd962b1b78..917952842892 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -52,7 +52,7 @@ use codex_app_server_protocol::ThreadGoalGetResponse; use codex_app_server_protocol::ThreadGoalSetResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; @@ -133,6 +133,7 @@ use core_test_support::streaming_sse::StreamingSseChunk; use core_test_support::streaming_sse::start_streaming_sse_server; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::HashMap; use std::fs::FileTimes; use std::io::Write; use std::path::Path; @@ -2736,8 +2737,8 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { let turn = &thread.turns[0]; assert_eq!(turn.status, TurnStatus::Completed); assert_eq!(turn.items.len(), 1, "expected user message item"); - match &turn.items[0] { - ThreadItem::UserMessage { content, .. } => { + match &turn.items[0].payload { + ThreadItemPayload::UserMessage { content, .. } => { assert_eq!( content, &vec![UserInput::Text { @@ -2772,16 +2773,16 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( let remote_mcp_item = remote_turn .items .iter() - .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) + .find(|item| matches!(&item.payload, ThreadItemPayload::McpToolCall { .. })) .expect("remote resume should include redacted MCP item"); - let ThreadItem::McpToolCall { + let ThreadItemPayload::McpToolCall { arguments, app_context, read_only_hint, result, error, .. - } = remote_mcp_item + } = &remote_mcp_item.payload else { unreachable!("matched MCP item"); }; @@ -2812,7 +2813,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( !remote_turn .items .iter() - .any(|item| matches!(item, ThreadItem::ImageGeneration(_))), + .any(|item| matches!(&item.payload, ThreadItemPayload::ImageGeneration(_))), "remote resume should drop image generation items for {client_name}" ); } @@ -2827,15 +2828,15 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( let normal_mcp_item = normal_turn .items .iter() - .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) + .find(|item| matches!(&item.payload, ThreadItemPayload::McpToolCall { .. })) .expect("normal resume should include MCP item"); - let ThreadItem::McpToolCall { + let ThreadItemPayload::McpToolCall { arguments, app_context, read_only_hint, result, .. - } = normal_mcp_item + } = &normal_mcp_item.payload else { unreachable!("matched MCP item"); }; @@ -2866,8 +2867,8 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( assert_eq!(result.meta, Some(json!({"secret":"meta"}))); assert!( normal_turn.items.iter().any(|item| matches!( - item, - ThreadItem::ImageGeneration(item) + &item.payload, + ThreadItemPayload::ImageGeneration(item) if matches!( &item.image, GeneratedImageRef::Inline { result } @@ -5364,6 +5365,8 @@ async fn thread_resume_rejoins_running_paginated_thread_with_initial_page() -> R gate: None, body: responses::sse(vec![ responses::ev_response_created("resp-2"), + responses::ev_message_item_added("progress", ""), + responses::ev_assistant_message("progress", "Checking"), responses::ev_message_item_added("msg-2", ""), ]), }, @@ -5438,17 +5441,17 @@ async fn thread_resume_rejoins_running_paginated_thread_with_initial_page() -> R primary.read_stream_until_notification_message("turn/started"), ) .await??; - // An assistant item starts after the user message has reached live history. - // The gated remainder of the response keeps the turn running during resume. + // Starting the gated reply means the prompt and the progress message have completed. + let mut message_starts = HashMap::new(); timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = primary.read_notification("item/started").await?; - if started.turn_id == running_turn.id - && matches!(started.item, ThreadItem::AgentMessage { .. }) - { + assert_eq!(started.turn_id, running_turn.id); + if started.item.id() == "msg-2" { return Ok::<(), anyhow::Error>(()); } + message_starts.insert(started.item.id().to_owned(), started.started_at_ms); } }) .await??; @@ -5550,7 +5553,18 @@ async fn thread_resume_rejoins_running_paginated_thread_with_initial_page() -> R page.data[0].items_view, items_view.unwrap_or(TurnItemsView::Summary) ); - assert!(!page.data[0].items.is_empty()); + assert!(matches!( + page.data[0].items.as_slice(), + [codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::UserMessage { .. }, .. }, codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::AgentMessage { text, .. }, .. }] if text == "Checking" + )); + assert_eq!( + page.data[0] + .items + .iter() + .filter_map(|item| Some((item.id().to_owned(), item.started_at_ms?))) + .collect::>(), + message_starts + ); if !exclude_turns { let full_turn = resumed.thread.turns.last().expect("full active turn"); assert_eq!(full_turn.id, running_turn.id); @@ -5899,14 +5913,14 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< .await?; let started: ItemStartedNotification = serde_json::from_value(notification.params.clone().expect("item/started params"))?; - if let ThreadItem::FileChange { .. } = started.item { - return Ok::(started.item); + if let ThreadItemPayload::FileChange { .. } = started.item.payload { + return Ok::(started.item.payload); } } }) .await??; let expected_readme_path = workspace.join("README.md"); - let expected_file_change = ThreadItem::FileChange { + let expected_file_change = ThreadItemPayload::FileChange { id: "patch-call".to_string(), changes: vec![codex_app_server_protocol::FileUpdateChange { path: expected_readme_path.to_string_lossy().into_owned(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index a9c2dd19bf52..561b890d18d5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -20,7 +20,7 @@ use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadShellCommandParams; @@ -86,9 +86,9 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut .await?; let started = wait_for_command_execution_started(&mut mcp, /*expected_id*/ None).await?; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, source, status, .. - } = &started.item + } = &started.item.payload else { unreachable!("helper returns command execution item"); }; @@ -103,14 +103,14 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut ); let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, source, status, aggregated_output, exit_code, .. - } = &completed.item + } = &completed.item.payload else { unreachable!("helper returns command execution item"); }; @@ -279,9 +279,9 @@ async fn check_thread_shell_command_in_active_turn(timeout_ms: Option) -> R let started: TurnStartedNotification = mcp.read_notification("turn/started").await?; assert_eq!(started.turn.id, turn.id); let agent_started = wait_for_command_execution_started(&mut mcp, Some("call-approve")).await?; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { command, source, .. - } = &agent_started.item + } = &agent_started.item.payload else { unreachable!("helper returns command execution item"); }; @@ -315,19 +315,19 @@ async fn check_thread_shell_command_in_active_turn(timeout_ms: Option) -> R wait_for_command_execution_started_by_source(&mut mcp, CommandExecutionSource::UserShell) .await?; assert_eq!(started.turn_id, turn.id); - let command_id = match &started.item { - ThreadItem::CommandExecution { id, .. } => id.clone(), + let command_id = match &started.item.payload { + ThreadItemPayload::CommandExecution { id, .. } => id.clone(), _ => unreachable!("helper returns command execution item"), }; let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; assert_eq!(completed.turn_id, turn.id); - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { source, aggregated_output, status, exit_code, .. - } = &completed.item + } = &completed.item.payload else { unreachable!("helper returns command execution item"); }; @@ -443,12 +443,12 @@ async fn thread_shell_command_honors_optional_timeout() -> Result<()> { let completed = wait_for_command_execution_completed(&mut mcp, /*expected_id*/ None).await?; assert_eq!(completed.turn_id, started.turn.id); - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { status, exit_code, aggregated_output, .. - } = completed.item + } = completed.item.payload else { unreachable!("helper returns command execution item"); }; @@ -544,11 +544,11 @@ fn current_shell_slow_command() -> Result<(String, String)> { Ok((format!("{command}{suffix}"), expected_output)) } -fn assert_no_command_executions(items: &[ThreadItem], context: &str) { +fn assert_no_command_executions(items: &[codex_app_server_protocol::ThreadItem], context: &str) { assert!( items .iter() - .all(|item| !matches!(item, ThreadItem::CommandExecution { .. })), + .all(|entry| !matches!(&entry.payload, ThreadItemPayload::CommandExecution { .. })), "{context} should always exclude command executions from returned turns" ); } @@ -577,7 +577,7 @@ async fn wait_for_command_execution_started( ) -> Result { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - let ThreadItem::CommandExecution { id, .. } = &started.item else { + let ThreadItemPayload::CommandExecution { id, .. } = &started.item.payload else { continue; }; if expected_id.is_none() || expected_id == Some(id.as_str()) { @@ -592,7 +592,7 @@ async fn wait_for_command_execution_started_by_source( ) -> Result { loop { let started = wait_for_command_execution_started(mcp, /*expected_id*/ None).await?; - let ThreadItem::CommandExecution { source, .. } = &started.item else { + let ThreadItemPayload::CommandExecution { source, .. } = &started.item.payload else { continue; }; if source == &expected_source { @@ -607,7 +607,7 @@ async fn wait_for_command_execution_completed( ) -> Result { loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - let ThreadItem::CommandExecution { id, .. } = &completed.item else { + let ThreadItemPayload::CommandExecution { id, .. } = &completed.item.payload else { continue; }; if expected_id.is_none() || expected_id == Some(id.as_str()) { diff --git a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs index 46a2a0b1bd2f..732f5460c8c4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs @@ -4,7 +4,7 @@ use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::SortDirection; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::ThreadRealtimeItemContent; @@ -164,7 +164,9 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() -> &page.data[0], ThreadTimelineEntry::Item { turn_id, item, .. } if turn_id == "turn-1" - && matches!(item.as_ref(), ThreadItem::UserMessage { .. }) + && matches!(&item.payload, ThreadItemPayload::UserMessage { .. }) + && item.started_at_ms == Some(0) + && item.completed_at_ms == Some(1) )); assert!(matches!( &page.data[1], diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index 67e5d1bdda1c..16273cf31e6a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -12,7 +12,7 @@ use codex_app_server_protocol::DynamicToolSpec; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadClosedNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadReadParams; @@ -611,7 +611,8 @@ async fn wait_for_dynamic_tool_started( continue; }; let started: ItemStartedNotification = serde_json::from_value(params)?; - if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) { + if matches!(&started.item.payload, ThreadItemPayload::DynamicToolCall { id, .. } if id == call_id) + { return Ok(started); } } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 822643bc7e36..0f8b25c9e858 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -52,7 +52,7 @@ use codex_app_server_protocol::ThreadDeleteResponse; use codex_app_server_protocol::ThreadDeletedNotification; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadInjectItemsParams; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadMetadataUpdateParams; @@ -607,7 +607,7 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { assert_eq!(completed.turn.items_view, TurnItemsView::Summary); assert!(matches!( &completed.turn.items[..], - [ThreadItem::AgentMessage { text, .. }] if text == "Done" + [codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::AgentMessage { text, .. }, .. }] if text == "Done" )); let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; @@ -972,15 +972,15 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { loop { let item_started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::UserMessage { .. } = item_started.item { - return Ok::(item_started.item); + if let ThreadItemPayload::UserMessage { .. } = item_started.item.payload { + return Ok::(item_started.item.payload); } } }) .await??; match user_message_item { - ThreadItem::UserMessage { + ThreadItemPayload::UserMessage { client_id, content, .. } => { assert_eq!(client_id, Some("client-message-1".to_string())); @@ -2249,12 +2249,12 @@ allow_upstream_proxy = false loop { let completed: ItemCompletedNotification = app_server.read_notification("item/completed").await?; - if let ThreadItem::CommandExecution { + if let ThreadItemPayload::CommandExecution { id, status, exit_code, .. - } = completed.item + } = completed.item.payload && id == call_id { assert_eq!( @@ -3030,6 +3030,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { params.command.as_deref(), Some(expected_approval_command.as_str()) ); + let approval_started_at_ms = params.started_at_ms; let resolved_request_id = request_id.clone(); // Approve and wait for task completion @@ -3051,14 +3052,15 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { "item/completed" => { let completed: ItemCompletedNotification = serde_json::from_value(notification.params.expect("item/completed params"))?; - match completed.item { - ThreadItem::CommandExecution { + match completed.item.payload { + ThreadItemPayload::CommandExecution { id, command, exit_code, aggregated_output, .. } if id == "call1" => { + assert_eq!(completed.item.started_at_ms, Some(approval_started_at_ms)); assert_eq!(command, expected_display_command); assert_eq!(exit_code, Some(0)); assert!(aggregated_output.is_some_and(|output| output.contains("True"))); @@ -3236,13 +3238,13 @@ async fn run_turn_start_exec_approval_rejection_v2( let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::CommandExecution { .. } = started.item { - return Ok::(started.item); + if let ThreadItemPayload::CommandExecution { .. } = started.item.payload { + return Ok::(started.item.payload); } } }) .await??; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, status, command, @@ -3283,13 +3285,13 @@ async fn run_turn_start_exec_approval_rejection_v2( loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - if let ThreadItem::CommandExecution { .. } = completed.item { - return Ok::(completed.item); + if let ThreadItemPayload::CommandExecution { .. } = completed.item.payload { + return Ok::(completed.item.payload); } } }) .await??; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, status, command, @@ -3494,13 +3496,16 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns( loop { let item_started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if matches!(item_started.item, ThreadItem::CommandExecution { .. }) { - return Ok::(item_started.item); + if matches!( + item_started.item.payload, + ThreadItemPayload::CommandExecution { .. } + ) { + return Ok::(item_started.item.payload); } } }) .await??; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { cwd, command, status, @@ -3868,13 +3873,13 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { let started_file_change = timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::FileChange { .. } = started.item { - return Ok::(started.item); + if let ThreadItemPayload::FileChange { .. } = started.item.payload { + return Ok::(started.item.payload); } } }) .await??; - let ThreadItem::FileChange { + let ThreadItemPayload::FileChange { ref id, status, ref changes, @@ -3917,7 +3922,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { ) .await?; let mut saw_resolved = false; - let mut completed_file_change: Option = None; + let mut completed_file_change: Option = None; while completed_file_change.is_none() { let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; let JSONRPCMessage::Notification(notification) = message else { @@ -3939,9 +3944,9 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { let completed: ItemCompletedNotification = serde_json::from_value( notification.params.clone().expect("item/completed params"), )?; - if let ThreadItem::FileChange { .. } = completed.item { + if let ThreadItemPayload::FileChange { .. } = completed.item.payload { assert!(saw_resolved, "serverRequest/resolved should arrive first"); - completed_file_change = Some(completed.item); + completed_file_change = Some(completed.item.payload); } } _ => {} @@ -3949,7 +3954,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { } let completed_file_change = completed_file_change.expect("file change completion should be observed"); - let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else { + let ThreadItemPayload::FileChange { ref id, status, .. } = completed_file_change else { unreachable!("loop ensures we break on file change items"); }; assert_eq!(id, "patch-call"); @@ -4333,17 +4338,17 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() let spawn_started = timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::CollabAgentToolCall { id, .. } = &started.item + if let ThreadItemPayload::CollabAgentToolCall { id, .. } = &started.item.payload && id == SPAWN_CALL_ID { - return Ok::(started.item); + return Ok::(started.item.payload); } } }) .await??; assert_eq!( spawn_started, - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { id: SPAWN_CALL_ID.to_string(), tool: CollabAgentTool::SpawnAgent, status: CollabAgentToolCallStatus::InProgress, @@ -4360,15 +4365,15 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - if let ThreadItem::CollabAgentToolCall { id, .. } = &completed.item + if let ThreadItemPayload::CollabAgentToolCall { id, .. } = &completed.item.payload && id == SPAWN_CALL_ID { - return Ok::(completed.item); + return Ok::(completed.item.payload); } } }) .await??; - let ThreadItem::CollabAgentToolCall { + let ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -4577,15 +4582,15 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected( let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; assert!(!matches!( - &completed.item, - ThreadItem::CollabAgentToolCall { .. } + &completed.item.payload, + ThreadItemPayload::CollabAgentToolCall { .. } )); - if let ThreadItem::SubAgentActivity { + if let ThreadItemPayload::SubAgentActivity { id, kind: SubAgentActivityKind::Started, agent_thread_id, .. - } = completed.item + } = completed.item.payload && id == SPAWN_CALL_ID { return Ok::(agent_thread_id); @@ -4993,15 +4998,15 @@ config_file = "./custom-role.toml" loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - if let ThreadItem::CollabAgentToolCall { id, .. } = &completed.item + if let ThreadItemPayload::CollabAgentToolCall { id, .. } = &completed.item.payload && id == SPAWN_CALL_ID { - return Ok::(completed.item); + return Ok::(completed.item.payload); } } }) .await??; - let ThreadItem::CollabAgentToolCall { + let ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -5128,13 +5133,13 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let started_file_change_1 = timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::FileChange { .. } = started.item { - return Ok::(started.item); + if let ThreadItemPayload::FileChange { .. } = started.item.payload { + return Ok::(started.item.payload); } } }) .await??; - let ThreadItem::FileChange { id, status, .. } = started_file_change_1 else { + let ThreadItemPayload::FileChange { id, status, .. } = started_file_change_1 else { unreachable!("loop ensures we break on file change items"); }; assert_eq!(id, "patch-call-1"); @@ -5181,7 +5186,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res "item/completed" => { let completed: ItemCompletedNotification = serde_json::from_value(notification.params.expect("item/completed params"))?; - if matches!(completed.item, ThreadItem::FileChange { ref id, .. } if id == "patch-call-1") + if matches!(completed.item.payload, ThreadItemPayload::FileChange { ref id, .. } if id == "patch-call-1") { assert_eq!(completed.thread_id, thread.id); assert_eq!(completed.turn_id, turn_1.id); @@ -5220,13 +5225,13 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let started_file_change_2 = timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::FileChange { .. } = started.item { - return Ok::(started.item); + if let ThreadItemPayload::FileChange { .. } = started.item.payload { + return Ok::(started.item.payload); } } }) .await??; - let ThreadItem::FileChange { id, status, .. } = started_file_change_2 else { + let ThreadItemPayload::FileChange { id, status, .. } = started_file_change_2 else { unreachable!("loop ensures we break on file change items"); }; assert_eq!(id, "patch-call-2"); @@ -5244,7 +5249,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let completed: ItemCompletedNotification = serde_json::from_value( notification.params.expect("item/completed params"), )?; - if matches!(completed.item, ThreadItem::FileChange { ref id, .. } if id == "patch-call-2") + if matches!(completed.item.payload, ThreadItemPayload::FileChange { ref id, .. } if id == "patch-call-2") { return Ok::(completed); } @@ -5356,13 +5361,13 @@ async fn run_turn_start_file_change_approval_rejection_v2( let started_file_change = timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::FileChange { .. } = started.item { - return Ok::(started.item); + if let ThreadItemPayload::FileChange { .. } = started.item.payload { + return Ok::(started.item.payload); } } }) .await??; - let ThreadItem::FileChange { + let ThreadItemPayload::FileChange { ref id, status, ref changes, @@ -5402,13 +5407,13 @@ async fn run_turn_start_file_change_approval_rejection_v2( loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - if let ThreadItem::FileChange { .. } = completed.item { - return Ok::(completed.item); + if let ThreadItemPayload::FileChange { .. } = completed.item.payload { + return Ok::(completed.item.payload); } } }) .await??; - let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else { + let ThreadItemPayload::FileChange { ref id, status, .. } = completed_file_change else { unreachable!("loop ensures we break on file change items"); }; assert_eq!(id, "patch-call"); @@ -5506,13 +5511,13 @@ async fn command_execution_notifications_include_process_id( let started_command = timeout(DEFAULT_READ_TIMEOUT, async { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if let ThreadItem::CommandExecution { .. } = started.item { - return Ok::(started.item); + if let ThreadItemPayload::CommandExecution { .. } = started.item.payload { + return Ok::(started.item.payload); } } }) .await??; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, process_id: started_process_id, status, @@ -5529,13 +5534,13 @@ async fn command_execution_notifications_include_process_id( loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - if let ThreadItem::CommandExecution { .. } = completed.item { - return Ok::(completed.item); + if let ThreadItemPayload::CommandExecution { .. } = completed.item.payload { + return Ok::(completed.item.payload); } } }) .await??; - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id: completed_id, process_id: completed_process_id, status: completed_status, @@ -5691,8 +5696,8 @@ async fn command_execution_notifications_include_trusted_plugin_id( let notification = mcp.read_stream_until_notification_message(method).await?; let params = notification.params.expect("item notification params"); let item_json = params.get("item").expect("item notification item").clone(); - let item = serde_json::from_value::(item_json.clone())?; - if let ThreadItem::CommandExecution { status, .. } = item { + let item = serde_json::from_value::(item_json.clone())?; + if let ThreadItemPayload::CommandExecution { status, .. } = item { let emitted_script_path = item_json .get("scriptPath") .and_then(serde_json::Value::as_str) diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 374b537c3cf8..9d4966a44aaf 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -16,7 +16,7 @@ use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -318,9 +318,9 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let params = notification.params.expect("item/started params"); let item_started: ItemStartedNotification = serde_json::from_value(params).expect("deserialize item/started notification"); - let ThreadItem::UserMessage { + let ThreadItemPayload::UserMessage { client_id, content, .. - } = item_started.item + } = item_started.item.payload else { continue; }; diff --git a/codex-rs/app-server/tests/suite/v2/view_image.rs b/codex-rs/app-server/tests/suite/v2/view_image.rs index 8ea35efabdde..7844903415cc 100644 --- a/codex-rs/app-server/tests/suite/v2/view_image.rs +++ b/codex-rs/app-server/tests/suite/v2/view_image.rs @@ -8,7 +8,7 @@ use app_test_support::create_final_assistant_message_sse_response; use app_test_support::write_models_cache; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ItemCompletedNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; @@ -143,7 +143,8 @@ async fn fresh_context_subagent_inherits_disabled_view_image_and_mcp_tools() -> mcp.read_notification("item/completed"), ) .await??; - if matches!(&completed.item, ThreadItem::McpToolCall { id, .. } if id == MCP_CALL_ID) { + if matches!(&completed.item.payload, ThreadItemPayload::McpToolCall { id, .. } if id == MCP_CALL_ID) + { assert_ne!(completed.thread_id, thread.id); break completed.thread_id; } diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index e282c4d78195..45254ac3f4b0 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -9,11 +9,12 @@ use app_test_support::TestAppServer; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; @@ -304,15 +305,15 @@ async fn assert_standalone_web_search_round_trips_output( }) ); assert_eq!( - started.item, - ThreadItem::WebSearch(WebSearchItem { + started.item.payload, + ThreadItemPayload::WebSearch(WebSearchItem { id: call_id.to_string(), query: String::new(), action: None, results: None, }) ); - let expected_completed_item = ThreadItem::WebSearch(WebSearchItem { + let expected_completed_item = ThreadItemPayload::WebSearch(WebSearchItem { id: call_id.to_string(), query: "standalone web search".to_string(), action: Some(WebSearchAction::Search { @@ -328,7 +329,7 @@ async fn assert_standalone_web_search_round_trips_output( "future_field": {"preserved": true}, })]), }); - assert_eq!(completed.item, expected_completed_item); + assert_eq!(completed.item.payload, expected_completed_item); drop(mcp); let mut reloaded_mcp = TestAppServer::builder() @@ -344,11 +345,11 @@ async fn assert_standalone_web_search_round_trips_output( .await?; let ThreadReadResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.read_response(read_req)).await??; - let persisted_web_searches: Vec<&ThreadItem> = thread + let persisted_web_searches: Vec<&ThreadItemPayload> = thread .turns .iter() - .flat_map(|turn| &turn.items) - .filter(|item| matches!(item, ThreadItem::WebSearch(_))) + .flat_map(Turn::item_payloads) + .filter(|item| matches!(item, ThreadItemPayload::WebSearch(_))) .collect(); assert_eq!(persisted_web_searches, vec![&expected_completed_item]); @@ -358,7 +359,7 @@ async fn assert_standalone_web_search_round_trips_output( async fn wait_for_web_search_started(mcp: &mut TestAppServer) -> Result { loop { let started: ItemStartedNotification = mcp.read_notification("item/started").await?; - if matches!(&started.item, ThreadItem::WebSearch(_)) { + if matches!(&started.item.payload, ThreadItemPayload::WebSearch(_)) { return Ok(started); } } @@ -369,7 +370,7 @@ async fn wait_for_web_search_completed( ) -> Result { loop { let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?; - if matches!(&completed.item, ThreadItem::WebSearch(_)) { + if matches!(&completed.item.payload, ThreadItemPayload::WebSearch(_)) { return Ok(completed); } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 073e7a19ee4a..8e5f7cf0fab1 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2500,6 +2500,14 @@ impl Session { /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { + if let Some((item_id, started_at_ms)) = msg.command_approval_start() { + // The client can see the command before execution. Keep its first start + // for the canonical lifecycle events that will be saved in history. + turn_context + .turn_timing_state + .record_item_started(item_id.to_owned(), started_at_ms) + .await; + } let legacy_source = msg.clone(); if let EventMsg::Error(error) = &legacy_source && error diff --git a/codex-rs/core/tests/suite/guardian_retry.rs b/codex-rs/core/tests/suite/guardian_retry.rs index e6eeac038b40..30fdbc6e9061 100644 --- a/codex-rs/core/tests/suite/guardian_retry.rs +++ b/codex-rs/core/tests/suite/guardian_retry.rs @@ -308,9 +308,13 @@ async fn guardian_retry_executes_only_after_a_completed_approval() -> Result<()> .await?; let mut reviews = Vec::new(); let mut warnings = Vec::new(); + let mut command_started_at_ms = None; loop { match test.codex.next_event().await?.msg { - EventMsg::GuardianAssessment(review) => reviews.push(review.status), + EventMsg::GuardianAssessment(review) => reviews.push(review), + EventMsg::ItemCompleted(event) if event.item.id() == "write-marker" => { + command_started_at_ms = event.started_at_ms; + } EventMsg::GuardianWarning(warning) => warnings.push(warning.message), EventMsg::TurnComplete(_) => break, _ => {} @@ -338,12 +342,16 @@ async fn guardian_retry_executes_only_after_a_completed_approval() -> Result<()> assert_eq!(request.inputs_of_type("agent_message"), expected_messages); } assert_eq!( - reviews, + reviews + .iter() + .map(|review| review.status) + .collect::>(), vec![ GuardianAssessmentStatus::InProgress, GuardianAssessmentStatus::Approved, ] ); + assert_eq!(command_started_at_ms, Some(reviews[0].started_at_ms)); assert_eq!( warnings.len(), 1, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index c4b773aae557..3d0dc1640138 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -3937,7 +3937,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) -> ThreadTimelineEntry::Realtime { item, .. } if matches!(&item.content, ThreadRealtimeItemContent::TranscriptSegment { text, .. } if text == "spoken before typed input") => Some("speech"), ThreadTimelineEntry::Item { item, .. } - if matches!(item.as_ref(), codex_app_server_protocol::ThreadItem::UserMessage { .. }) => Some("typed input"), + if matches!(&item.payload, codex_app_server_protocol::ThreadItemPayload::UserMessage { .. }) => Some("typed input"), _ => None, }).collect::>(), vec!["speech", "typed input"] diff --git a/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs b/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs index 501c35f8dbeb..120cdcbefb9a 100644 --- a/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs +++ b/codex-rs/core/tests/suite/unified_exec_launch_failure_tests.rs @@ -6,7 +6,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use codex_app_server_protocol::ThreadHistoryBuilder; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnStatus; use codex_config::Constrained; use codex_config::test_support::CloudConfigBundleFixture; @@ -203,14 +203,15 @@ async fn interrupted_one_shot_launch_failure_completes_and_persists() -> Result< .context("interrupted turn in persisted history")?; assert_eq!(turn.status, TurnStatus::Interrupted); let commands = turn - .items - .into_iter() + .item_payloads() .filter(|item| item.id() == CALL_ID) .collect::>(); // History reconstructs persisted fields; model_context is only present on live items. assert_eq!( commands, - vec![ThreadItem::from(persisted_completions[0].item.clone())] + vec![&ThreadItemPayload::from( + persisted_completions[0].item.clone() + )] ); assert!( matches!(aborted, EventMsg::TurnAborted(event) if event.turn_id.as_deref() == Some(completed[0].turn_id.as_str())) diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 37f1a0479520..0a6ceeabce5d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -5,7 +5,7 @@ use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; @@ -67,16 +67,16 @@ impl EventProcessorWithHumanOutput { } } - fn render_item_started(&self, item: &ThreadItem) { + fn render_item_started(&self, item: &ThreadItemPayload) { match item { - ThreadItem::CommandExecution { command, cwd, .. } => { + ThreadItemPayload::CommandExecution { command, cwd, .. } => { eprintln!( "{}\n{} in {cwd}", "exec".style(self.italic).style(self.magenta), command.style(self.bold), ); } - ThreadItem::McpToolCall { server, tool, .. } => { + ThreadItemPayload::McpToolCall { server, tool, .. } => { eprintln!( "{} {} {}", "mcp:".style(self.bold), @@ -84,22 +84,22 @@ impl EventProcessorWithHumanOutput { "started".style(self.dimmed) ); } - ThreadItem::WebSearch(item) => { + ThreadItemPayload::WebSearch(item) => { eprintln!("{} {}", "web search:".style(self.bold), item.query); } - ThreadItem::FileChange { .. } => { + ThreadItemPayload::FileChange { .. } => { eprintln!("{}", "apply patch".style(self.bold)); } - ThreadItem::CollabAgentToolCall { tool, .. } => { + ThreadItemPayload::CollabAgentToolCall { tool, .. } => { eprintln!("{} {:?}", "collab:".style(self.bold), tool); } _ => {} } } - fn render_item_completed(&mut self, item: ThreadItem) { + fn render_item_completed(&mut self, item: ThreadItemPayload) { match item { - ThreadItem::AgentMessage { text, .. } => { + ThreadItemPayload::AgentMessage { text, .. } => { eprintln!( "{}\n{}", "codex".style(self.italic).style(self.magenta), @@ -108,7 +108,7 @@ impl EventProcessorWithHumanOutput { self.final_message = Some(text); self.final_message_rendered = true; } - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { summary, content, .. } => { if self.show_agent_reasoning @@ -119,7 +119,7 @@ impl EventProcessorWithHumanOutput { eprintln!("{}", text.style(self.dimmed)); } } - ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { command: _, aggregated_output, exit_code, @@ -163,7 +163,7 @@ impl EventProcessorWithHumanOutput { eprintln!("{output}"); } } - ThreadItem::FileChange { + ThreadItemPayload::FileChange { changes, status, .. } => { let status_text = match status { @@ -177,7 +177,7 @@ impl EventProcessorWithHumanOutput { eprintln!("{}", change.path.style(self.dimmed)); } } - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { server, tool, status, @@ -199,10 +199,10 @@ impl EventProcessorWithHumanOutput { eprintln!("{}", error.message.style(self.red)); } } - ThreadItem::WebSearch(item) => { + ThreadItemPayload::WebSearch(item) => { eprintln!("{} {}", "web search:".style(self.bold), item.query); } - ThreadItem::ContextCompaction { .. } => { + ThreadItemPayload::ContextCompaction { .. } => { eprintln!("{}", "context compacted".style(self.dimmed)); } _ => {} @@ -287,11 +287,11 @@ impl EventProcessor for EventProcessorWithHumanOutput { CodexStatus::Running } ServerNotification::ItemStarted(notification) => { - self.render_item_started(¬ification.item); + self.render_item_started(¬ification.item.payload); CodexStatus::Running } ServerNotification::ItemCompleted(notification) => { - self.render_item_completed(notification.item); + self.render_item_completed(notification.item.payload); CodexStatus::Running } ServerNotification::ModelRerouted(notification) => { @@ -499,17 +499,19 @@ fn reasoning_text( } } -fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { +fn final_message_from_turn_items( + items: &[impl std::borrow::Borrow], +) -> Option { items .iter() .rev() - .find_map(|item| match item { - ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + .find_map(|item| match item.borrow() { + ThreadItemPayload::AgentMessage { text, .. } => Some(text.clone()), _ => None, }) .or_else(|| { - items.iter().rev().find_map(|item| match item { - ThreadItem::Plan { text, .. } => Some(text.clone()), + items.iter().rev().find_map(|item| match item.borrow() { + ThreadItemPayload::Plan { text, .. } => Some(text.clone()), _ => None, }) }) diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index c16e505ce008..179e99bddb67 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -1,5 +1,6 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; use codex_core::config::ConfigBuilder; @@ -352,7 +353,7 @@ async fn config_summary_entries_include_runtime_workspace_roots() { #[test] fn final_message_from_turn_items_uses_latest_agent_message() { let message = final_message_from_turn_items(&[ - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "first".to_string(), phase: None, @@ -360,11 +361,11 @@ fn final_message_from_turn_items_uses_latest_agent_message() { delivery: None, questions: None, }, - ThreadItem::Plan { + ThreadItemPayload::Plan { id: "plan-1".to_string(), text: "plan".to_string(), }, - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id: "msg-2".to_string(), text: "second".to_string(), phase: None, @@ -380,16 +381,16 @@ fn final_message_from_turn_items_uses_latest_agent_message() { #[test] fn final_message_from_turn_items_falls_back_to_latest_plan() { let message = final_message_from_turn_items(&[ - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: vec!["inspect".to_string()], content: Vec::new(), }, - ThreadItem::Plan { + ThreadItemPayload::Plan { id: "plan-1".to_string(), text: "first plan".to_string(), }, - ThreadItem::Plan { + ThreadItemPayload::Plan { id: "plan-2".to_string(), text: "final plan".to_string(), }, @@ -426,14 +427,14 @@ fn turn_completed_recovers_final_message_from_turn_items() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::AgentMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, - }], + })], status: TurnStatus::Completed, error: None, started_at: None, @@ -478,14 +479,14 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::AgentMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, - }], + })], status: TurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 73e0c9d911c8..a92f762096ad 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -10,7 +10,7 @@ use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; @@ -141,15 +141,15 @@ impl EventProcessorWithJsonOutput { } fn map_item_with_id( - item: ThreadItem, + item: ThreadItemPayload, make_id: impl FnOnce() -> String, ) -> Option { match item { - ThreadItem::AgentMessage { text, .. } => Some(ExecThreadItem { + ThreadItemPayload::AgentMessage { text, .. } => Some(ExecThreadItem { id: make_id(), details: ThreadItemDetails::AgentMessage(AgentMessageItem { text }), }), - ThreadItem::Reasoning { summary, .. } => { + ThreadItemPayload::Reasoning { summary, .. } => { let text = summary.join("\n"); if text.trim().is_empty() { return None; @@ -159,7 +159,7 @@ impl EventProcessorWithJsonOutput { details: ThreadItemDetails::Reasoning(ReasoningItem { text }), }) } - ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { command, aggregated_output, exit_code, @@ -179,7 +179,7 @@ impl EventProcessorWithJsonOutput { }, }), }), - ThreadItem::FileChange { + ThreadItemPayload::FileChange { changes, status, .. } => Some(ExecThreadItem { id: make_id(), @@ -204,7 +204,7 @@ impl EventProcessorWithJsonOutput { }, }), }), - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { server, tool, status, @@ -233,7 +233,7 @@ impl EventProcessorWithJsonOutput { }), }), }), - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { tool, sender_thread_id, receiver_thread_ids, @@ -300,7 +300,7 @@ impl EventProcessorWithJsonOutput { }, }), }), - ThreadItem::WebSearch(item) => Some(ExecThreadItem { + ThreadItemPayload::WebSearch(item) => Some(ExecThreadItem { id: make_id(), details: ThreadItemDetails::WebSearch(WebSearchItem { id: item.id, @@ -340,9 +340,9 @@ impl EventProcessorWithJsonOutput { .unwrap_or_else(|| self.next_item_id()) } - fn map_started_item(&mut self, item: ThreadItem) -> Option { + fn map_started_item(&mut self, item: ThreadItemPayload) -> Option { match item { - ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => None, + ThreadItemPayload::AgentMessage { .. } | ThreadItemPayload::Reasoning { .. } => None, other => { let raw_id = other.id().to_string(); Self::map_item_with_id(other, || self.started_item_id(&raw_id)) @@ -350,14 +350,14 @@ impl EventProcessorWithJsonOutput { } } - fn map_completed_item_mut(&mut self, item: ThreadItem) -> Option { - if let ThreadItem::Reasoning { summary, .. } = &item + fn map_completed_item_mut(&mut self, item: ThreadItemPayload) -> Option { + if let ThreadItemPayload::Reasoning { summary, .. } = &item && summary.join("\n").trim().is_empty() { return None; } match &item { - ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => { + ThreadItemPayload::AgentMessage { .. } | ThreadItemPayload::Reasoning { .. } => { Self::map_item_with_id(item, || self.next_item_id()) } other => { @@ -369,7 +369,7 @@ impl EventProcessorWithJsonOutput { fn reconcile_unfinished_started_items( &mut self, - turn_items: &[ThreadItem], + turn_items: &[codex_app_server_protocol::ThreadItem], ) -> Vec { turn_items .iter() @@ -378,23 +378,25 @@ impl EventProcessorWithJsonOutput { if !self.raw_to_exec_item_id.contains_key(&raw_id) { return None; } - self.map_completed_item_mut(item.clone()) + self.map_completed_item_mut(item.payload.clone()) .map(|item| ThreadEvent::ItemCompleted(ItemCompletedEvent { item })) }) .collect() } - fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { + fn final_message_from_turn_items( + items: &[impl std::borrow::Borrow], + ) -> Option { items .iter() .rev() - .find_map(|item| match item { - ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + .find_map(|item| match item.borrow() { + ThreadItemPayload::AgentMessage { text, .. } => Some(text.clone()), _ => None, }) .or_else(|| { - items.iter().rev().find_map(|item| match item { - ThreadItem::Plan { text, .. } => Some(text.clone()), + items.iter().rev().find_map(|item| match item.borrow() { + ThreadItemPayload::Plan { text, .. } => Some(text.clone()), _ => None, }) }) @@ -475,13 +477,13 @@ impl EventProcessorWithJsonOutput { CodexStatus::Running } ServerNotification::ItemStarted(notification) => { - if let Some(item) = self.map_started_item(notification.item) { + if let Some(item) = self.map_started_item(notification.item.payload) { events.push(ThreadEvent::ItemStarted(ItemStartedEvent { item })); } CodexStatus::Running } ServerNotification::ItemCompleted(notification) => { - if let Some(item) = self.map_completed_item_mut(notification.item) { + if let Some(item) = self.map_completed_item_mut(notification.item.payload) { if let ThreadItemDetails::AgentMessage(AgentMessageItem { text }) = &item.details { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index 9082f912ce69..7205c8ec4782 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -12,8 +12,10 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone())); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - item: ThreadItem::AgentMessage { + codex_app_server_protocol::ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "partial answer".to_string(), phase: None, @@ -21,10 +23,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!(collected.status, CodexStatus::Running); @@ -95,8 +96,10 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - item: ThreadItem::McpToolCall { + codex_app_server_protocol::ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "search service".to_string(), tool: "web_run".to_string(), @@ -115,10 +118,9 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { error: None, duration_ms: Some(42), }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!(collected.status, CodexStatus::Running); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a6e9abe9bdfc..a8761fdfafcf 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -39,7 +39,6 @@ use codex_app_server_protocol::Thread as AppServerThread; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem as AppServerThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadReadParams; @@ -1793,7 +1792,7 @@ fn should_backfill_turn_completed_items( fn turn_items_for_thread( thread: &AppServerThread, turn_id: &str, -) -> Option> { +) -> Option> { thread .turns .iter() diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index d2e20e5f39be..6079fb5a7dd4 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -1,5 +1,7 @@ use super::*; use codex_app_server_protocol::AuthRecoveryNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload as AppServerThreadItemPayload; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ActivePermissionProfile; @@ -387,14 +389,16 @@ fn turn_items_for_thread_returns_matching_turn_items() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![AppServerThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "hello".to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }], + items: vec![ThreadItem::untimed( + AppServerThreadItemPayload::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + )], status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, @@ -405,10 +409,10 @@ fn turn_items_for_thread_returns_matching_turn_items() { id: "turn-2".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![AppServerThreadItem::Plan { + items: vec![ThreadItem::untimed(AppServerThreadItemPayload::Plan { id: "plan-1".to_string(), text: "ship it".to_string(), - }], + })], status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, @@ -420,14 +424,16 @@ fn turn_items_for_thread_returns_matching_turn_items() { assert_eq!( turn_items_for_thread(&thread, "turn-1"), - Some(vec![AppServerThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "hello".to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }]) + Some(vec![ThreadItem::untimed( + AppServerThreadItemPayload::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + } + )]) ); assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None); } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index c1cc8947495c..4771e96c3e23 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::PatchApplyStatus as ApiPatchApplyStatus; use codex_app_server_protocol::PatchChangeKind as ApiPatchChangeKind; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::TokenUsageBreakdown; use codex_app_server_protocol::Turn; @@ -168,7 +169,7 @@ fn turn_started_emits_turn_started_event() { #[test] fn command_execution_started_and_completed_translate_to_thread_events() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); - let command_item = ThreadItem::CommandExecution { + let command_item = ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "cmd-1".to_string(), @@ -185,13 +186,14 @@ fn command_execution_started_and_completed_translate_to_thread_events() { duration_ms: None, }; - let started = - processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: command_item, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - })); + let started = processor.collect_thread_events(ServerNotification::ItemStarted( + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + command_item, + /*started_at_ms*/ 0, + ), + )); assert_eq!( started, CollectedThreadEvents { @@ -211,8 +213,10 @@ fn command_execution_started_and_completed_translate_to_thread_events() { ); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::CommandExecution { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "cmd-1".to_string(), @@ -228,10 +232,9 @@ fn command_execution_started_and_completed_translate_to_thread_events() { exit_code: Some(0), duration_ms: Some(3), }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( completed, @@ -257,16 +260,17 @@ fn empty_reasoning_items_are_ignored() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::Reasoning { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: Vec::new(), content: vec!["raw reasoning".to_string()], }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -283,15 +287,16 @@ fn unsupported_items_do_not_consume_synthetic_ids() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let ignored = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::Plan { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::Plan { id: "plan-1".to_string(), text: "ignored plan".to_string(), }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -303,8 +308,10 @@ fn unsupported_items_do_not_consume_synthetic_ids() { ); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::AgentMessage { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "message-1".to_string(), text: "hello".to_string(), phase: None, @@ -312,10 +319,9 @@ fn unsupported_items_do_not_consume_synthetic_ids() { delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -339,16 +345,17 @@ fn reasoning_items_emit_summary_not_raw_content() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::Reasoning { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: vec!["safe summary".to_string()], content: vec!["raw reasoning".to_string()], }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -372,8 +379,10 @@ fn web_search_completion_preserves_query_and_action() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::WebSearch(ApiWebSearchItem { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: "rust async await".to_string(), action: Some(ApiWebSearchAction::Search { @@ -382,10 +391,9 @@ fn web_search_completion_preserves_query_and_action() { }), results: None, }), - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -436,17 +444,18 @@ fn web_search_page_actions_and_results_survive_json_output() { ] { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::WebSearch(ApiWebSearchItem { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: url.to_string(), action: Some(action.clone()), results: results.clone(), }), - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); let mut expected_item = json!({ @@ -470,22 +479,25 @@ fn web_search_page_actions_and_results_survive_json_output() { fn web_search_start_and_completion_reuse_item_id() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); - let started = - processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::WebSearch(ApiWebSearchItem { + let started = processor.collect_thread_events(ServerNotification::ItemStarted( + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: String::new(), action: None, results: None, }), - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - })); + /*started_at_ms*/ 0, + ), + )); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::WebSearch(ApiWebSearchItem { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::WebSearch(ApiWebSearchItem { id: "search-1".to_string(), query: "rust async await".to_string(), action: Some(ApiWebSearchAction::Search { @@ -494,10 +506,9 @@ fn web_search_start_and_completion_reuse_item_id() { }), results: None, }), - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -543,9 +554,11 @@ fn web_search_start_and_completion_reuse_item_id() { fn mcp_tool_call_begin_and_end_emit_item_events() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); - let started = - processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::McpToolCall { + let started = processor.collect_thread_events(ServerNotification::ItemStarted( + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server_a".to_string(), tool: "tool_x".to_string(), @@ -560,13 +573,14 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { error: None, duration_ms: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - })); + /*started_at_ms*/ 0, + ), + )); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::McpToolCall { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "server_a".to_string(), tool: "tool_x".to_string(), @@ -585,10 +599,9 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { error: None, duration_ms: Some(1_000), }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -640,8 +653,10 @@ fn mcp_tool_call_failure_sets_failed_status() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::McpToolCall { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::McpToolCall { id: "mcp-2".to_string(), server: "server_b".to_string(), tool: "tool_y".to_string(), @@ -658,10 +673,9 @@ fn mcp_tool_call_failure_sets_failed_status() { }), duration_ms: Some(5), }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -691,9 +705,11 @@ fn mcp_tool_call_failure_sets_failed_status() { fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); - let started = - processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::McpToolCall { + let started = processor.collect_thread_events(ServerNotification::ItemStarted( + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::McpToolCall { id: "mcp-3".to_string(), server: "server_c".to_string(), tool: "tool_z".to_string(), @@ -708,13 +724,14 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { error: None, duration_ms: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - })); + /*started_at_ms*/ 0, + ), + )); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::McpToolCall { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::McpToolCall { id: "mcp-3".to_string(), server: "server_c".to_string(), tool: "tool_z".to_string(), @@ -736,10 +753,9 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { error: None, duration_ms: Some(10), }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -793,9 +809,11 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { fn collab_spawn_begin_and_end_emit_item_events() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); - let started = - processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::CollabAgentToolCall { + let started = processor.collect_thread_events(ServerNotification::ItemStarted( + ItemStartedNotification::new( + "thread-parent".to_string(), + "turn-1".to_string(), + ThreadItemPayload::CollabAgentToolCall { id: "collab-1".to_string(), tool: CollabAgentTool::SpawnAgent, status: ApiCollabAgentToolCallStatus::InProgress, @@ -806,13 +824,14 @@ fn collab_spawn_begin_and_end_emit_item_events() { reasoning_effort: None, agents_states: std::collections::HashMap::new(), }, - thread_id: "thread-parent".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - })); + /*started_at_ms*/ 0, + ), + )); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::CollabAgentToolCall { + ItemCompletedNotification::new( + "thread-parent".to_string(), + "turn-1".to_string(), + ThreadItemPayload::CollabAgentToolCall { id: "collab-1".to_string(), tool: CollabAgentTool::SpawnAgent, status: ApiCollabAgentToolCallStatus::Completed, @@ -829,10 +848,9 @@ fn collab_spawn_begin_and_end_emit_item_events() { }, )]), }, - thread_id: "thread-parent".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -886,8 +904,10 @@ fn file_change_completion_maps_change_kinds() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::FileChange { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::FileChange { id: "patch-1".to_string(), changes: vec![ ApiFileUpdateChange { @@ -908,10 +928,9 @@ fn file_change_completion_maps_change_kinds() { ], status: ApiPatchApplyStatus::Completed, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -949,8 +968,10 @@ fn file_change_declined_maps_to_failed_status() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::FileChange { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::FileChange { id: "patch-2".to_string(), changes: vec![ApiFileUpdateChange { path: "file.txt".to_string(), @@ -959,10 +980,9 @@ fn file_change_declined_maps_to_failed_status() { }], status: ApiPatchApplyStatus::Declined, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -990,8 +1010,10 @@ fn agent_message_item_updates_final_message() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::AgentMessage { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "hello".to_string(), phase: None, @@ -999,10 +1021,9 @@ fn agent_message_item_updates_final_message() { delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -1026,9 +1047,11 @@ fn agent_message_item_updates_final_message() { fn agent_message_item_started_is_ignored() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); - let collected = - processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::AgentMessage { + let collected = processor.collect_thread_events(ServerNotification::ItemStarted( + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "hello".to_string(), phase: None, @@ -1036,10 +1059,9 @@ fn agent_message_item_started_is_ignored() { delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - })); + /*started_at_ms*/ 0, + ), + )); assert_eq!( collected, @@ -1055,16 +1077,17 @@ fn reasoning_item_completed_uses_synthetic_id() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::Reasoning { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::Reasoning { id: "rs-1".to_string(), summary: vec!["thinking...".to_string()], content: vec!["raw".to_string()], }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!( @@ -1384,14 +1407,14 @@ fn turn_completion_recovers_final_message_from_turn_items() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::AgentMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, - }], + })], status: TurnStatus::Completed, error: None, started_at: None, @@ -1417,9 +1440,11 @@ fn turn_completion_recovers_final_message_from_turn_items() { fn turn_completion_reconciles_started_items_from_turn_items() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); - let started = - processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { - item: ThreadItem::CommandExecution { + let started = processor.collect_thread_events(ServerNotification::ItemStarted( + ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "cmd-1".to_string(), @@ -1435,10 +1460,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() { exit_code: None, duration_ms: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - })); + /*started_at_ms*/ 0, + ), + )); assert_eq!( started, CollectedThreadEvents { @@ -1464,7 +1488,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::CommandExecution { + items: vec![ThreadItem::untimed(ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "cmd-1".to_string(), @@ -1479,7 +1503,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { aggregated_output: Some("a.txt\n".to_string()), exit_code: Some(0), duration_ms: Some(3), - }], + })], status: TurnStatus::Completed, error: None, started_at: None, @@ -1517,8 +1541,10 @@ fn turn_completion_reconciles_started_items_from_turn_items() { fn turn_completion_overwrites_stale_final_message_from_turn_items() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::AgentMessage { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "msg-stale".to_string(), text: "stale answer".to_string(), phase: None, @@ -1526,10 +1552,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( @@ -1539,14 +1564,14 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::AgentMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, - }], + })], status: TurnStatus::Completed, error: None, started_at: None, @@ -1572,8 +1597,10 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::AgentMessage { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "msg-streamed".to_string(), text: "streamed answer".to_string(), phase: None, @@ -1581,10 +1608,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( @@ -1621,8 +1647,10 @@ fn failed_turn_clears_stale_final_message() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::AgentMessage { + ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "partial answer".to_string(), phase: None, @@ -1630,10 +1658,9 @@ fn failed_turn_clears_stale_final_message() { delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); assert_eq!(collected.status, CodexStatus::Running); @@ -1676,10 +1703,10 @@ fn turn_completion_falls_back_to_final_plan_text() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::Plan { + items: vec![ThreadItem::untimed(ThreadItemPayload::Plan { id: "plan-1".to_string(), text: "ship the typed adapter".to_string(), - }], + })], status: TurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/external-agent-migration/src/sessions/export.rs b/codex-rs/external-agent-migration/src/sessions/export.rs index 3900ea12931f..d9067e2e4a58 100644 --- a/codex-rs/external-agent-migration/src/sessions/export.rs +++ b/codex-rs/external-agent-migration/src/sessions/export.rs @@ -227,7 +227,7 @@ fn turn_complete_item( #[cfg(test)] mod tests { use super::*; - use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::build_turns_from_rollout_items; use serde_json::Value as JsonValue; use std::path::Path; @@ -258,15 +258,15 @@ mod tests { assert_eq!(turns[0].items.len(), 2); assert_eq!(turns[1].items.len(), 2); assert_eq!( - turns[1].items[1], - ThreadItem::AgentMessage { + turns[1].item_payloads().next_back(), + Some(&ThreadItemPayload::AgentMessage { id: "item-4".into(), text: EXTERNAL_SESSION_IMPORTED_MARKER.into(), phase: None, memory_citation: None, delivery: None, questions: None, - } + }) ); } @@ -292,8 +292,8 @@ mod tests { assert_eq!(turns.len(), 1); assert_eq!( - turns[0].items.last(), - Some(&ThreadItem::AgentMessage { + turns[0].item_payloads().next_back(), + Some(&ThreadItemPayload::AgentMessage { id: "item-3".into(), text: EXTERNAL_SESSION_IMPORTED_MARKER.into(), phase: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 727422c79540..586dd6f0fe90 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1695,6 +1695,34 @@ pub enum EventMsg { SubAgentActivity(SubAgentActivityEvent), } +impl EventMsg { + /// Returns the ID and start time of the command this approval opens. + /// Stdin and network reviews have their own prompts for an existing operation. + pub fn command_approval_start(&self) -> Option<(&str, i64)> { + match self { + Self::ExecApprovalRequest(request) + if request.approval_id.is_none() && request.network_approval_context.is_none() => + { + Some((&request.call_id, request.started_at_ms)) + } + Self::GuardianAssessment(assessment) + if assessment.status == GuardianAssessmentStatus::InProgress + && matches!( + assessment.action, + GuardianAssessmentAction::Command { .. } + | GuardianAssessmentAction::Execve { .. } + ) => + { + Some(( + assessment.target_item_id.as_deref()?, + assessment.started_at_ms, + )) + } + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)] #[serde(rename_all = "snake_case")] pub enum HookEventName { diff --git a/codex-rs/thread-store/src/local/thread_history.rs b/codex-rs/thread-store/src/local/thread_history.rs index 7be33d672086..1685cb3e2845 100644 --- a/codex-rs/thread-store/src/local/thread_history.rs +++ b/codex-rs/thread-store/src/local/thread_history.rs @@ -1,5 +1,5 @@ use codex_app_server_protocol::ThreadHistoryChangeSet; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; use codex_protocol::models::MessagePhase; @@ -428,7 +428,8 @@ WHERE thread_id = ? .map_err(thread_history_error)?; } - for item in changes.changed_items { + for change in changes.changed_items { + let item = change.item; let created_at_ms = item.started_at_ms .or(fallback_created_at_ms) @@ -437,8 +438,8 @@ WHERE thread_id = ? "thread history projection for {thread_id} is missing an item creation timestamp" ), })?; - let item_id = item.item.id().to_string(); - let item_json = serde_json::to_string(&item.item).map_err(thread_history_error)?; + let item_id = item.id().to_string(); + let item_json = serde_json::to_string(&item.payload).map_err(thread_history_error)?; // Completed items are immutable: local producers emit ItemCompleted exactly once per // item. Tolerate an unexpected duplicate defensively so it cannot poison materialization, // preserving the original creation ordinal and timestamp while updating its snapshot. @@ -465,7 +466,7 @@ ON CONFLICT(thread_id, turn_id, item_id) DO UPDATE SET "#, ) .bind(thread_id) - .bind(item.turn_id.as_str()) + .bind(change.turn_id.as_str()) .bind(item_id.as_str()) .bind(rollout_ordinal) .bind(rollout_ordinal) @@ -480,8 +481,8 @@ ON CONFLICT(thread_id, turn_id, item_id) DO UPDATE SET // Keep summary item IDs on the turn row so reads do not need to scan every item in the // turn. - match item.item { - ThreadItem::UserMessage { .. } => { + match item.payload { + ThreadItemPayload::UserMessage { .. } => { sqlx::query( r#" UPDATE thread_turns @@ -494,12 +495,12 @@ WHERE thread_id = ? ) .bind(item_id.as_str()) .bind(thread_id) - .bind(item.turn_id.as_str()) + .bind(change.turn_id.as_str()) .execute(&mut **transaction) .await .map_err(thread_history_error)?; } - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { phase: Some(MessagePhase::FinalAnswer), .. } => { @@ -515,32 +516,32 @@ WHERE thread_id = ? ) .bind(item_id.as_str()) .bind(thread_id) - .bind(item.turn_id.as_str()) + .bind(change.turn_id.as_str()) .execute(&mut **transaction) .await .map_err(thread_history_error)?; } - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { phase: Some(MessagePhase::Commentary | MessagePhase::PartialAnswer) | None, .. } - | ThreadItem::HookPrompt { .. } - | ThreadItem::FunctionCallOutput { .. } - | ThreadItem::Plan { .. } - | ThreadItem::Reasoning { .. } - | ThreadItem::CommandExecution { .. } - | ThreadItem::FileChange { .. } - | ThreadItem::McpToolCall { .. } - | ThreadItem::DynamicToolCall { .. } - | ThreadItem::CollabAgentToolCall { .. } - | ThreadItem::SubAgentActivity { .. } - | ThreadItem::WebSearch(_) - | ThreadItem::ImageView { .. } - | ThreadItem::Sleep(_) - | ThreadItem::ImageGeneration(_) - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } - | ThreadItem::ContextCompaction { .. } => {} + | ThreadItemPayload::HookPrompt { .. } + | ThreadItemPayload::FunctionCallOutput { .. } + | ThreadItemPayload::Plan { .. } + | ThreadItemPayload::Reasoning { .. } + | ThreadItemPayload::CommandExecution { .. } + | ThreadItemPayload::FileChange { .. } + | ThreadItemPayload::McpToolCall { .. } + | ThreadItemPayload::DynamicToolCall { .. } + | ThreadItemPayload::CollabAgentToolCall { .. } + | ThreadItemPayload::SubAgentActivity { .. } + | ThreadItemPayload::WebSearch(_) + | ThreadItemPayload::ImageView { .. } + | ThreadItemPayload::Sleep(_) + | ThreadItemPayload::ImageGeneration(_) + | ThreadItemPayload::EnteredReviewMode { .. } + | ThreadItemPayload::ExitedReviewMode { .. } + | ThreadItemPayload::ContextCompaction { .. } => {} } } Ok(()) diff --git a/codex-rs/thread-store/src/local/thread_history/realtime.rs b/codex-rs/thread-store/src/local/thread_history/realtime.rs index cfb897caf349..af0209fd478e 100644 --- a/codex-rs/thread-store/src/local/thread_history/realtime.rs +++ b/codex-rs/thread-store/src/local/thread_history/realtime.rs @@ -97,7 +97,18 @@ pub(in crate::local) async fn list_timeline( .map(|value| sqlite_integer(value, "rollout ordinal")) .transpose()? .unwrap_or(i64::MAX); - let segment_rows = sqlx::query_as::<_, (i64, i64, String, Option, String)>( + let segment_rows = sqlx::query_as::< + _, + ( + i64, + i64, + String, + Option, + String, + Option, + Option, + ), + >( r#" WITH starts AS ( SELECT rollout_ordinal, 0 AS kind, turn_id AS id, turn_id, @@ -109,7 +120,7 @@ WHERE thread_id = ?1 AND rollout_ordinal >= ?2 AND (rollout_ordinal, 0, turn_id) < (?4, ?5, ?6) ORDER BY rollout_ordinal DESC, turn_id DESC LIMIT ?7 ), items AS ( -SELECT rollout_ordinal, 1 AS kind, item_id AS id, turn_id, item_json +SELECT rollout_ordinal, 1 AS kind, item_id AS id, turn_id, item_json, started_at_ms, completed_at_ms FROM thread_items WHERE thread_id = ?1 AND rollout_ordinal >= ?2 AND rollout_ordinal < ?3 AND rollout_ordinal <= ?4 @@ -134,10 +145,10 @@ WHERE thread_id = ?1 AND rollout_end_ordinal >= ?2 AND (rollout_end_ordinal, 3, turn_id) < (?4, ?5, ?6) ORDER BY rollout_end_ordinal DESC, turn_id DESC LIMIT ?7 ) -SELECT * FROM starts +SELECT *, NULL, NULL FROM starts UNION ALL SELECT * FROM items -UNION ALL SELECT * FROM realtime -UNION ALL SELECT * FROM ends +UNION ALL SELECT *, NULL, NULL FROM realtime +UNION ALL SELECT *, NULL, NULL FROM ends ORDER BY rollout_ordinal DESC, kind DESC, id DESC LIMIT ?7 "#, @@ -153,7 +164,9 @@ LIMIT ?7 .await .map_err(thread_history_error)?; - for (position, kind, _id, turn_id, item_json) in segment_rows { + for (position, kind, _id, turn_id, item_json, started_at_ms, completed_at_ms) in + segment_rows + { let position = u64::try_from(position).map_err(thread_history_error)?; rows.push(match kind { 0 | 3 => serde_json::from_str::(&item_json) @@ -161,10 +174,11 @@ LIMIT ?7 1 => ThreadTimelineEntry::Item { position, turn_id: turn_id.ok_or_else(|| thread_history_error("missing turn ID"))?, - item: Box::new( - serde_json::from_str::(&item_json) - .map_err(thread_history_error)?, - ), + item: Box::new(ThreadItem { + payload: serde_json::from_str(&item_json).map_err(thread_history_error)?, + started_at_ms, + completed_at_ms, + }), }, 2 => ThreadTimelineEntry::Realtime { position, diff --git a/codex-rs/thread-store/src/local/thread_history/search.rs b/codex-rs/thread-store/src/local/thread_history/search.rs index 0fe9f4de4f5f..6aeb6d576acd 100644 --- a/codex-rs/thread-store/src/local/thread_history/search.rs +++ b/codex-rs/thread-store/src/local/thread_history/search.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use std::collections::HashMap; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::UserInput; use codex_protocol::ThreadId; use codex_protocol::protocol::strip_user_message_prefix; @@ -176,12 +176,11 @@ ORDER BY rollout_ordinal ASC let mut matching_occurrences = 0_usize; while let Some(row) = rows.try_next().await.map_err(thread_history_error)? { let row = candidate_row(row)?; - let item = - serde_json::from_str::(row.item_json.as_str()).map_err(|err| { - ThreadStoreError::Internal { - message: format!("failed to deserialize stored thread item: {err}"), - } - })?; + let item = serde_json::from_str::(row.item_json.as_str()).map_err( + |err| ThreadStoreError::Internal { + message: format!("failed to deserialize stored thread item: {err}"), + }, + )?; let Some(text) = searchable_text(&item) else { continue; }; @@ -309,9 +308,9 @@ fn invalid_cursor(cursor: &str) -> ThreadStoreError { } } -fn searchable_text(item: &ThreadItem) -> Option> { +fn searchable_text(item: &ThreadItemPayload) -> Option> { match item { - ThreadItem::UserMessage { content, .. } => { + ThreadItemPayload::UserMessage { content, .. } => { let mut text_parts = content .iter() .filter_map(|input| match input { @@ -335,27 +334,27 @@ fn searchable_text(item: &ThreadItem) -> Option> { } } } - ThreadItem::AgentMessage { text, .. } => { + ThreadItemPayload::AgentMessage { text, .. } => { let text = markdown_to_search_text(text); (!text.is_empty()).then_some(Cow::Owned(text)) } - ThreadItem::HookPrompt { .. } - | ThreadItem::FunctionCallOutput { .. } - | ThreadItem::Plan { .. } - | ThreadItem::Reasoning { .. } - | ThreadItem::CommandExecution { .. } - | ThreadItem::FileChange { .. } - | ThreadItem::McpToolCall { .. } - | ThreadItem::DynamicToolCall { .. } - | ThreadItem::CollabAgentToolCall { .. } - | ThreadItem::SubAgentActivity { .. } - | ThreadItem::WebSearch(_) - | ThreadItem::ImageView { .. } - | ThreadItem::Sleep(_) - | ThreadItem::ImageGeneration(_) - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } - | ThreadItem::ContextCompaction { .. } => None, + ThreadItemPayload::HookPrompt { .. } + | ThreadItemPayload::FunctionCallOutput { .. } + | ThreadItemPayload::Plan { .. } + | ThreadItemPayload::Reasoning { .. } + | ThreadItemPayload::CommandExecution { .. } + | ThreadItemPayload::FileChange { .. } + | ThreadItemPayload::McpToolCall { .. } + | ThreadItemPayload::DynamicToolCall { .. } + | ThreadItemPayload::CollabAgentToolCall { .. } + | ThreadItemPayload::SubAgentActivity { .. } + | ThreadItemPayload::WebSearch(_) + | ThreadItemPayload::ImageView { .. } + | ThreadItemPayload::Sleep(_) + | ThreadItemPayload::ImageGeneration(_) + | ThreadItemPayload::EnteredReviewMode { .. } + | ThreadItemPayload::ExitedReviewMode { .. } + | ThreadItemPayload::ContextCompaction { .. } => None, } } diff --git a/codex-rs/thread-store/src/local/thread_history_materialization.rs b/codex-rs/thread-store/src/local/thread_history_materialization.rs index e28fff1152a5..4d408c3beefb 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization.rs @@ -236,7 +236,7 @@ fn read_projection_batches( let fallback_created_at_ms = if changes .changed_items .iter() - .any(|item| item.started_at_ms.is_none()) + .any(|change| change.item.started_at_ms.is_none()) || (!is_inherited_subagent_history && matches!(&line.item, RolloutItem::RealtimeItem(_))) { diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index c9e2fe285906..ce951add86de 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -7,7 +7,7 @@ use std::time::Duration; use chrono::Utc; use codex_app_server_protocol::McpToolCallResult; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_protocol::ThreadId; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; @@ -499,9 +499,9 @@ async fn paginated_command_history_caps_aggregated_output() { .fetch_one(&pool) .await .expect("read projected command"); - let projected: ThreadItem = + let projected: ThreadItemPayload = serde_json::from_str(&item_json).expect("deserialize projected command"); - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { aggregated_output: Some(output), .. } = projected @@ -573,9 +573,9 @@ async fn paginated_mcp_history_caps_large_results() { .fetch_one(&pool) .await .expect("read projected MCP tool call"); - let projected: ThreadItem = + let projected: ThreadItemPayload = serde_json::from_str(&item_json).expect("deserialize projected MCP tool call"); - let ThreadItem::McpToolCall { + let ThreadItemPayload::McpToolCall { arguments: projected_arguments, result: Some(result), .. @@ -1678,8 +1678,8 @@ WHERE thread_id = ? AND turn_id = ? AND item_id = ? .expect("read projected item"); assert_eq!((item.0, item.1, item.2), (2, 3, first_created_at_ms)); assert_eq!( - serde_json::from_str::(item.3.as_str()).expect("parse projected item"), - ThreadItem::UserMessage { + serde_json::from_str::(item.3.as_str()).expect("parse projected item"), + ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: Some("updated".to_string()), content: Vec::new(), diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 4a55334b105c..79497861caf1 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -543,7 +543,7 @@ pub struct StoredThreadItem { pub started_at_ms: Option, /// Exact lifecycle completion time in Unix milliseconds, when recorded by the producer. pub completed_at_ms: Option, - /// Serialized app-server ThreadItem snapshot. + /// Serialized app-server item payload. Its lifecycle times are stored in the columns above. pub item_json: Vec, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1b3b21e07f20..f3326b44bb78 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -130,7 +130,7 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillErrorInfo; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadMemoryMode; use codex_app_server_protocol::ThreadSettingsUpdateParams; @@ -322,15 +322,15 @@ enum ThreadInteractiveRequest { /// receiver thread ids. All other notification variants return `None`. fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[String]> { match notification { - ServerNotification::ItemStarted(notification) => match ¬ification.item { - ThreadItem::CollabAgentToolCall { + ServerNotification::ItemStarted(notification) => match ¬ification.item.payload { + ThreadItemPayload::CollabAgentToolCall { receiver_thread_ids, .. } => Some(receiver_thread_ids), _ => None, }, - ServerNotification::ItemCompleted(notification) => match ¬ification.item { - ThreadItem::CollabAgentToolCall { + ServerNotification::ItemCompleted(notification) => match ¬ification.item.payload { + ThreadItemPayload::CollabAgentToolCall { receiver_thread_ids, .. } => Some(receiver_thread_ids), @@ -340,14 +340,14 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str } } -fn sub_agent_activity_item(notification: &ServerNotification) -> Option<&ThreadItem> { +fn sub_agent_activity_item(notification: &ServerNotification) -> Option<&ThreadItemPayload> { match notification { - ServerNotification::ItemStarted(notification) => match ¬ification.item { - ThreadItem::SubAgentActivity { .. } => Some(¬ification.item), + ServerNotification::ItemStarted(notification) => match ¬ification.item.payload { + ThreadItemPayload::SubAgentActivity { .. } => Some(¬ification.item.payload), _ => None, }, - ServerNotification::ItemCompleted(notification) => match ¬ification.item { - ThreadItem::SubAgentActivity { .. } => Some(¬ification.item), + ServerNotification::ItemCompleted(notification) => match ¬ification.item.payload { + ThreadItemPayload::SubAgentActivity { .. } => Some(¬ification.item.payload), _ => None, }, _ => None, @@ -359,8 +359,8 @@ fn collab_receiver_is_not_found( receiver_thread_id: &str, ) -> bool { match notification { - ServerNotification::ItemCompleted(notification) => match ¬ification.item { - ThreadItem::CollabAgentToolCall { agents_states, .. } => { + ServerNotification::ItemCompleted(notification) => match ¬ification.item.payload { + ThreadItemPayload::CollabAgentToolCall { agents_states, .. } => { agents_states.get(receiver_thread_id).is_some_and(|state| { matches!( &state.status, @@ -627,7 +627,7 @@ pub(crate) struct App { windows_sandbox: WindowsSandboxState, thread_event_channels: HashMap, - pending_realtime_speech_replay: HashMap>, + pending_realtime_speech_replay: HashMap>, pending_realtime_transcript_replay: HashMap>, realtime_replay_order: VecDeque, diff --git a/codex-rs/tui/src/app/activity_groups_tests.rs b/codex-rs/tui/src/app/activity_groups_tests.rs index 4e5ae718ce74..3a70965e898a 100644 --- a/codex-rs/tui/src/app/activity_groups_tests.rs +++ b/codex-rs/tui/src/app/activity_groups_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::app::test_support::make_test_app; use crate::thread_transcript::RawReasoningVisibility; use crate::thread_transcript::thread_items_to_transcript_cells; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnItemsView; use pretty_assertions::assert_eq; @@ -13,7 +14,7 @@ enum ActivityKind { Exploration, } -fn call(id: &str, kind: ActivityKind, status: &str) -> ThreadItem { +fn call(id: &str, kind: ActivityKind, status: &str) -> ThreadItemPayload { let value = match kind { ActivityKind::Computer => serde_json::json!({ "type": "mcpToolCall", "id": id, "server": "cua_repl", "tool": "js", @@ -31,8 +32,8 @@ fn call(id: &str, kind: ActivityKind, status: &str) -> ThreadItem { serde_json::from_value(value).expect("activity fixture") } -fn reasoning(id: &str) -> ThreadItem { - ThreadItem::Reasoning { +fn reasoning(id: &str) -> ThreadItemPayload { + ThreadItemPayload::Reasoning { id: id.to_owned(), summary: vec![format!( "**Considering {id}**\n\nRetained reasoning for {id}." @@ -41,11 +42,11 @@ fn reasoning(id: &str) -> ThreadItem { } } -fn turn(items: Vec) -> Turn { +fn turn(items: Vec) -> Turn { Turn { id: "turn".to_owned(), root_turn_id: None, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), items_view: TurnItemsView::Full, status: TurnStatus::Completed, error: None, @@ -55,11 +56,14 @@ fn turn(items: Vec) -> Turn { } } -fn project(app: &App, items: &[ThreadItem]) -> Vec> { +fn project( + app: &App, + items: &[impl std::borrow::Borrow], +) -> Vec> { thread_items_to_transcript_cells( /*thread_id*/ None, &app.config.cwd, - items.iter().cloned(), + items.iter().map(|entry| entry.borrow().clone()), RawReasoningVisibility::Hidden, Some(&app.config), ) @@ -127,7 +131,7 @@ async fn older_page_hydration_keeps_pending_computer_and_exploration_completion( .chat_widget .handle_command_execution_completed_now(call("pending", kind, "completed")), } - current.items[3] = call("pending", kind, "completed"); + current.items[3] = ThreadItem::untimed(call("pending", kind, "completed")); let expected = project(&app, ¤t.items); assert_eq!( app.chat_widget @@ -146,7 +150,7 @@ async fn trailing_reasoning_identity_rejects_identical_text_from_another_turn() let mut same_turn = reasoning("same-turn-detail"); let mut next_turn = reasoning("next-turn-detail"); for item in [&mut same_turn, &mut next_turn] { - if let ThreadItem::Reasoning { summary, .. } = item { + if let ThreadItemPayload::Reasoning { summary, .. } = item { *summary = vec!["Exactly the same reasoning text".to_owned()]; } } @@ -191,7 +195,7 @@ async fn every_page_split_folds_reasoning_before_answer_and_completion_boundarie let mut app = make_test_app().await; let mut items = vec![call("call", kind, "completed"), reasoning("detail")]; if let Some(text) = answer { - items.push(ThreadItem::AgentMessage { + items.push(ThreadItemPayload::AgentMessage { id: "answer".to_owned(), text: text.to_owned(), phase: None, @@ -247,7 +251,7 @@ async fn every_page_split_folds_reasoning_before_answer_and_completion_boundarie async fn failed_exploration_retains_following_reasoning_at_every_page_split() { let mut app = make_test_app().await; let mut failed = call("failed", ActivityKind::Exploration, "failed"); - if let ThreadItem::CommandExecution { exit_code, .. } = &mut failed { + if let ThreadItemPayload::CommandExecution { exit_code, .. } = &mut failed { *exit_code = Some(1); } let current = turn(vec![ diff --git a/codex-rs/tui/src/app/agent_status_feed.rs b/codex-rs/tui/src/app/agent_status_feed.rs index bab6e5673731..1dee7b6dfca2 100644 --- a/codex-rs/tui/src/app/agent_status_feed.rs +++ b/codex-rs/tui/src/app/agent_status_feed.rs @@ -9,7 +9,7 @@ use crate::text_formatting::truncate_text; use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SubAgentActivityKind; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use ratatui::style::Stylize; use ratatui::text::Line; use std::collections::HashSet; @@ -98,7 +98,7 @@ impl AgentStatusThreadPreview { if !seen_item_ids.insert(item.id().to_string()) { continue; } - if let Some(summary) = activity_summary(item) { + if let Some(summary) = activity_summary(&item.payload) { activity.push(summary); if activity.len() == AGENT_STATUS_PREVIEW_ITEMS { break; @@ -135,24 +135,24 @@ impl AgentStatusThreadPreview { } } -fn activity_summary(item: &ThreadItem) -> Option { +fn activity_summary(item: &ThreadItemPayload) -> Option { let summary = match item { - ThreadItem::AgentMessage { text, .. } | ThreadItem::Plan { text, .. } => text, - ThreadItem::Reasoning { summary, .. } => summary.last()?, - ThreadItem::CommandExecution { command, .. } => { + ThreadItemPayload::AgentMessage { text, .. } | ThreadItemPayload::Plan { text, .. } => text, + ThreadItemPayload::Reasoning { summary, .. } => summary.last()?, + ThreadItemPayload::CommandExecution { command, .. } => { let command = truncate_text( command, AGENT_STATUS_PREVIEW_GRAPHEMES.saturating_sub("$ ".len()), ); return bounded_summary(&format!("$ {command}")); } - ThreadItem::FileChange { changes, .. } => { + ThreadItemPayload::FileChange { changes, .. } => { return bounded_summary(&format!("Updated {} file(s)", changes.len())); } - ThreadItem::McpToolCall { server, tool, .. } => { + ThreadItemPayload::McpToolCall { server, tool, .. } => { return bounded_summary(&format!("MCP {server}/{tool}")); } - ThreadItem::DynamicToolCall { + ThreadItemPayload::DynamicToolCall { namespace, tool, .. } => { let tool = namespace @@ -161,7 +161,7 @@ fn activity_summary(item: &ThreadItem) -> Option { .unwrap_or_else(|| tool.clone()); return bounded_summary(&format!("Tool {tool}")); } - ThreadItem::CollabAgentToolCall { tool, .. } => { + ThreadItemPayload::CollabAgentToolCall { tool, .. } => { let action = match tool { CollabAgentTool::SendMessage | CollabAgentTool::FollowupTask @@ -175,7 +175,7 @@ fn activity_summary(item: &ThreadItem) -> Option { }; return Some(action.to_string()); } - ThreadItem::SubAgentActivity { + ThreadItemPayload::SubAgentActivity { kind, agent_path, .. } => { let action = match kind { @@ -186,21 +186,27 @@ fn activity_summary(item: &ThreadItem) -> Option { }; return bounded_summary(&format!("{action} {agent_path}")); } - ThreadItem::WebSearch(item) => { + ThreadItemPayload::WebSearch(item) => { return bounded_summary(&format!("Web search: {}", item.query)); } - ThreadItem::ImageView { path, .. } => { + ThreadItemPayload::ImageView { path, .. } => { let path = path.render_for_ui(); return bounded_summary(&format!("Viewed {path}")); } - ThreadItem::ImageGeneration(_) => return Some("Generated an image".to_string()), - ThreadItem::EnteredReviewMode { .. } => return Some("Entered review mode".to_string()), - ThreadItem::ExitedReviewMode { .. } => return Some("Exited review mode".to_string()), - ThreadItem::ContextCompaction { .. } => return Some("Compacted context".to_string()), - ThreadItem::UserMessage { .. } - | ThreadItem::HookPrompt { .. } - | ThreadItem::FunctionCallOutput { .. } - | ThreadItem::Sleep(_) => { + ThreadItemPayload::ImageGeneration(_) => return Some("Generated an image".to_string()), + ThreadItemPayload::EnteredReviewMode { .. } => { + return Some("Entered review mode".to_string()); + } + ThreadItemPayload::ExitedReviewMode { .. } => { + return Some("Exited review mode".to_string()); + } + ThreadItemPayload::ContextCompaction { .. } => { + return Some("Compacted context".to_string()); + } + ThreadItemPayload::UserMessage { .. } + | ThreadItemPayload::HookPrompt { .. } + | ThreadItemPayload::FunctionCallOutput { .. } + | ThreadItemPayload::Sleep(_) => { return None; } }; diff --git a/codex-rs/tui/src/app/agent_status_feed_tests.rs b/codex-rs/tui/src/app/agent_status_feed_tests.rs index 395e5fb103e6..6ea67c2c97f9 100644 --- a/codex-rs/tui/src/app/agent_status_feed_tests.rs +++ b/codex-rs/tui/src/app/agent_status_feed_tests.rs @@ -8,8 +8,10 @@ use codex_utils_absolute_path::AbsolutePathBuf; fn agent_status_uses_bounded_buffered_activity() { let mut store = ThreadEventStore::new(/*capacity*/ 8); store.push_notification(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::CommandExecution { + ItemCompletedNotification::new( + "thread-child".to_string(), + "turn-1".to_string(), + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "command-1".to_string(), @@ -27,14 +29,15 @@ fn agent_status_uses_bounded_buffered_activity() { exit_code: Some(0), duration_ms: Some(42), }, - thread_id: "thread-child".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 1, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1, + ), )); store.push_notification(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::AgentMessage { + ItemCompletedNotification::new( + "thread-child".to_string(), + "turn-1".to_string(), + ThreadItemPayload::AgentMessage { id: "message-1".to_string(), text: "Finished checking the focused TUI tests.".to_string(), phase: None, @@ -42,10 +45,9 @@ fn agent_status_uses_bounded_buffered_activity() { delivery: None, questions: None, }, - thread_id: "thread-child".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 2, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 2, + ), )); let preview = AgentStatusThreadPreview::from_store("/root/reviewer".to_string(), &store); @@ -72,28 +74,30 @@ fn agent_status_uses_bounded_buffered_activity() { fn agent_status_uses_reasoning_summaries_only() { let mut store = ThreadEventStore::new(/*capacity*/ 8); store.push_notification(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::Reasoning { + ItemCompletedNotification::new( + "thread-child".to_string(), + "turn-1".to_string(), + ThreadItemPayload::Reasoning { id: "reasoning-with-summary".to_string(), summary: vec!["safe summary".to_string()], content: vec!["hidden raw reasoning".to_string()], }, - thread_id: "thread-child".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 1, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 1, + ), )); store.push_notification(ServerNotification::ItemCompleted( - ItemCompletedNotification { - item: ThreadItem::Reasoning { + ItemCompletedNotification::new( + "thread-child".to_string(), + "turn-1".to_string(), + ThreadItemPayload::Reasoning { id: "reasoning-without-summary".to_string(), summary: Vec::new(), content: vec!["raw-only reasoning".to_string()], }, - thread_id: "thread-child".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 2, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 2, + ), )); let preview = AgentStatusThreadPreview::from_store("/root/reviewer".to_string(), &store); diff --git a/codex-rs/tui/src/app/agents_overview_details.rs b/codex-rs/tui/src/app/agents_overview_details.rs index 1a86a59098f1..02146046433c 100644 --- a/codex-rs/tui/src/app/agents_overview_details.rs +++ b/codex-rs/tui/src/app/agents_overview_details.rs @@ -12,7 +12,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadActiveFlag; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadStatus; use codex_protocol::ThreadId; use codex_utils_absolute_path::AbsolutePathBuf; @@ -104,7 +104,11 @@ impl App { had_header || reasoning.header.is_some() } ServerNotification::ItemCompleted(ItemCompletedNotification { - item: ThreadItem::AgentMessage { text, .. }, + item: + codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::AgentMessage { text, .. }, + .. + }, .. }) => { activity.last_message = Some(preview_agent_message(text)); diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index ad1c7b95f209..57e9b691592d 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -929,19 +929,22 @@ async fn agents_overview_details_render_markdown() { app.agents_overview.last_messages.clear(); app.track_agents_overview_activity( thread_id, - &ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { - thread_id: thread.id.clone(), - turn_id: "turn".into(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { - id: "answer".into(), - text: message.into(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }, - }), + &ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + thread.id.clone(), + "turn".into(), + ThreadItemPayload::AgentMessage { + id: "answer".into(), + text: message.into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), ); let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); terminal @@ -1080,11 +1083,10 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm .threads .insert(parent_id, Some(parent.clone())); app.track_agents_overview_notification(&ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - thread_id: parent.id.clone(), - turn_id: "previous-turn".into(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { + codex_app_server_protocol::ItemCompletedNotification::new( + parent.id.clone(), + "previous-turn".into(), + ThreadItemPayload::AgentMessage { id: "answer".into(), text: "Previous answer".into(), phase: None, @@ -1092,7 +1094,9 @@ async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachm delivery: None, questions: None, }, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); let channel = app.thread_event_channels.remove(&thread_id).unwrap(); let details = app.agents_overview_details( diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index ab3e56f242a2..b4e2ca183657 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -18,7 +18,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadReadParams; @@ -400,16 +400,17 @@ impl App { .await && let Some(turn) = turns.data.first() { - if let Some(ThreadItem::UserMessage { content, .. }) = - turn.items.first() + if let Some(ThreadItemPayload::UserMessage { + content, .. + }) = turn.item_payloads().next() { response.thread.preview = ChatWidget::user_message_display_from_inputs(content) .message; } last_message = - turn.items.iter().rev().find_map(|item| match item { - ThreadItem::AgentMessage { text, .. } => { + turn.item_payloads().rev().find_map(|item| match item { + ThreadItemPayload::AgentMessage { text, .. } => { Some(preview_agent_message(text)) } _ => None, diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index cd8f1678d6a5..aa1564a54d07 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -25,7 +25,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadSource; @@ -469,10 +469,10 @@ impl App { ServerNotificationThreadTarget::Thread(thread_id) => { if self.current_displayed_thread_id() != Some(thread_id) && let ServerNotification::ItemCompleted(item) = ¬ification - && let ThreadItem::UserMessage { + && let ThreadItemPayload::UserMessage { client_id: Some(client_id), .. - } = &item.item + } = &item.item.payload { // Acknowledge by ID before routing can discard the receipt. ID-less receipts // cannot safely distinguish identical pending submissions. diff --git a/codex-rs/tui/src/app/history_completion.rs b/codex-rs/tui/src/app/history_completion.rs index 6b597bebc995..0081bbf1bf20 100644 --- a/codex-rs/tui/src/app/history_completion.rs +++ b/codex-rs/tui/src/app/history_completion.rs @@ -1,15 +1,15 @@ //! Separate older history at every turn boundary; only successful turns gain completion metadata. //! A page splitting a turn only adds completion metadata when it contains that turn's last item. -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; use std::collections::HashMap; pub(super) fn group_completed_turn_items( - items: Vec, + items: Vec, turns: &[Turn], -) -> Vec<(Vec, Option<&Turn>)> { +) -> Vec<(Vec, Option<&Turn>)> { let boundaries: HashMap<_, _> = turns .iter() .filter_map(|turn| turn.items.last().map(|item| (item.id(), turn))) diff --git a/codex-rs/tui/src/app/history_completion_tests.rs b/codex-rs/tui/src/app/history_completion_tests.rs index d82b71bb710a..a25ffb80fb9e 100644 --- a/codex-rs/tui/src/app/history_completion_tests.rs +++ b/codex-rs/tui/src/app/history_completion_tests.rs @@ -1,20 +1,26 @@ //! Pagination boundaries preserve grouped item rendering and attach completion metadata once. use super::*; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnItemsView; use pretty_assertions::assert_eq; +fn payloads(items: &[codex_app_server_protocol::ThreadItem]) -> Vec { + items.iter().map(|entry| entry.payload.clone()).collect() +} + fn turn(id: &str, status: TurnStatus, item_ids: &[&str]) -> Turn { Turn { id: id.to_string(), root_turn_id: None, items: item_ids .iter() - .map(|id| ThreadItem::UserMessage { + .map(|id| ThreadItemPayload::UserMessage { id: id.to_string(), client_id: None, content: Vec::new(), }) + .map(ThreadItem::untimed) .collect(), items_view: TurnItemsView::Summary, status, @@ -33,12 +39,12 @@ fn split_turn_only_completes_on_the_page_with_its_last_item() { &["first", "second", "last"], )]; assert_eq!( - group_completed_turn_items(turns[0].items[1..].to_vec(), &turns), - vec![(turns[0].items[1..].to_vec(), Some(&turns[0]))], + group_completed_turn_items(payloads(&turns[0].items[1..]), &turns), + vec![(payloads(&turns[0].items[1..]), Some(&turns[0]))], ); assert_eq!( - group_completed_turn_items(turns[0].items[..1].to_vec(), &turns), - vec![(turns[0].items[..1].to_vec(), None)], + group_completed_turn_items(payloads(&turns[0].items[..1]), &turns), + vec![(payloads(&turns[0].items[..1]), None)], ); } @@ -48,12 +54,16 @@ fn multiple_turns_keep_item_groups_and_completion_order() { turn("first", TurnStatus::Completed, &["a", "b"]), turn("second", TurnStatus::Completed, &["c", "d"]), ]; - let items = turns.iter().flat_map(|turn| turn.items.clone()).collect(); + let items = turns + .iter() + .flat_map(Turn::item_payloads) + .cloned() + .collect(); assert_eq!( group_completed_turn_items(items, &turns), vec![ - (turns[0].items.clone(), Some(&turns[0])), - (turns[1].items.clone(), Some(&turns[1])), + (payloads(&turns[0].items), Some(&turns[0])), + (payloads(&turns[1].items), Some(&turns[1])), ], ); } @@ -67,13 +77,14 @@ fn unsuccessful_and_running_turns_separate_groups_without_completion_metadata() ]; let items = turns .iter() - .flat_map(|turn| turn.items.clone()) + .flat_map(Turn::item_payloads) + .cloned() .collect::>(); assert_eq!( group_completed_turn_items(items, &turns), turns .iter() - .map(|turn| (turn.items.clone(), None)) + .map(|turn| (payloads(&turn.items), None)) .collect::>(), ); } diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index bd04368c10b9..0c9f0053fbb4 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -155,8 +155,8 @@ impl App { } let user_items = turns .iter() - .flat_map(|turn| turn.items.iter()) - .filter(|item| matches!(item, ThreadItem::UserMessage { .. })) + .flat_map(Turn::item_payloads) + .filter(|item| matches!(item, ThreadItemPayload::UserMessage { .. })) .map(|item| (item.id().to_string(), item.clone())) .collect::>(); let projected_user_cells = thread_items_to_transcript_cells( @@ -217,7 +217,7 @@ impl App { /// Project successful turn completion metadata after the corresponding page's final item. fn project_older_history_cells( &mut self, - items: Vec, + items: Vec, turns: &[Turn], hidden_item_ids: &HashSet<&str>, thread_id: ThreadId, @@ -363,10 +363,12 @@ fn hidden_review_item_ids(turns: &[Turn]) -> HashSet<&str> { crate::app_backtrack::is_hidden_nested_review_turn(previous, turn) }); for item in &turn.items { - match item { - ThreadItem::EnteredReviewMode { .. } => review_mode = true, - ThreadItem::ExitedReviewMode { .. } => review_mode = false, - ThreadItem::UserMessage { .. } if review_mode || hidden_nested_review_turn => { + match &item.payload { + ThreadItemPayload::EnteredReviewMode { .. } => review_mode = true, + ThreadItemPayload::ExitedReviewMode { .. } => review_mode = false, + ThreadItemPayload::UserMessage { .. } + if review_mode || hidden_nested_review_turn => + { hidden.insert(item.id()); } _ => {} diff --git a/codex-rs/tui/src/app/history_pagination_tests.rs b/codex-rs/tui/src/app/history_pagination_tests.rs index f33a9347e56a..69e996d6deda 100644 --- a/codex-rs/tui/src/app/history_pagination_tests.rs +++ b/codex-rs/tui/src/app/history_pagination_tests.rs @@ -5,6 +5,7 @@ use crate::app::test_support::make_test_app; use crate::history_cell::ComputerActivityCell; use crate::history_cell::FinalMessageSeparator; use crate::history_cell::ForkedThreadHistoryCell; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::UserInput; use pretty_assertions::assert_eq; @@ -15,11 +16,12 @@ fn turn(id: &str, status: TurnStatus, item_ids: &[&str]) -> Turn { root_turn_id: None, items: item_ids .iter() - .map(|id| ThreadItem::UserMessage { + .map(|id| ThreadItemPayload::UserMessage { id: id.to_string(), client_id: None, content: Vec::new(), }) + .map(ThreadItem::untimed) .collect(), items_view: TurnItemsView::Full, status, @@ -138,19 +140,19 @@ async fn hidden_last_item_keeps_turn_groups_and_completion_boundaries() { let expected_completions = usize::from(status == TurnStatus::Completed); let mut first = turn("first", status, &[]); first.items = vec![ - ThreadItem::EnteredReviewMode { + ThreadItem::untimed(ThreadItemPayload::EnteredReviewMode { id: "enter-review".to_string(), review: "current changes".to_string(), - }, + }), computer("older-call"), - ThreadItem::UserMessage { + ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "hidden".to_string(), client_id: None, content: vec![UserInput::Text { text: "internal review prompt".to_string(), text_elements: Vec::new(), }], - }, + }), ]; first.completed_at = Some(1_700_000_000); first.duration_ms = Some(125_000); @@ -161,7 +163,8 @@ async fn hidden_last_item_keeps_turn_groups_and_completion_boundaries() { assert_eq!(hidden, HashSet::from(["hidden"])); let items = turns .iter() - .flat_map(|turn| turn.items.clone()) + .flat_map(Turn::item_payloads) + .cloned() .collect::>(); let cells = app.project_older_history_cells( @@ -261,7 +264,7 @@ async fn browsing_waits_for_a_prompt_outside_the_initial_history_window() -> Res &["older prompt", "latest prompt"], ); for item in &mut review_turn.items { - if let ThreadItem::UserMessage { id, content, .. } = item { + if let ThreadItemPayload::UserMessage { id, content, .. } = &mut item.payload { *content = vec![UserInput::Text { text: id.clone(), text_elements: Vec::new(), @@ -270,17 +273,17 @@ async fn browsing_waits_for_a_prompt_outside_the_initial_history_window() -> Res } review_turn.items.insert( /*index*/ 0, - ThreadItem::EnteredReviewMode { + ThreadItem::untimed(ThreadItemPayload::EnteredReviewMode { id: "enter".to_string(), review: "review".to_string(), - }, + }), ); review_turn.items.insert( /*index*/ 2, - ThreadItem::ExitedReviewMode { + ThreadItem::untimed(ThreadItemPayload::ExitedReviewMode { id: "exit".to_string(), review: "review".to_string(), - }, + }), ); let turns = [review_turn]; app.remove_hidden_review_cells( diff --git a/codex-rs/tui/src/app/native_history_tests.rs b/codex-rs/tui/src/app/native_history_tests.rs index 6e340c424bb8..3521543cd517 100644 --- a/codex-rs/tui/src/app/native_history_tests.rs +++ b/codex-rs/tui/src/app/native_history_tests.rs @@ -49,7 +49,7 @@ async fn quota_warnings_are_emitted_once_and_survive_native_replay() -> Result<( async fn settled_tool_precedes_queued_output_and_is_not_emitted_twice() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; let mut tui = crate::tui::test_support::make_test_tui()?; - let item = |status| ThreadItem::DynamicToolCall { + let item = |status| ThreadItemPayload::DynamicToolCall { id: "lookup".into(), namespace: None, tool: "lookup".into(), diff --git a/codex-rs/tui/src/app/owned_transcript_input_tests.rs b/codex-rs/tui/src/app/owned_transcript_input_tests.rs index 15cf0bf8c914..075a6256623b 100644 --- a/codex-rs/tui/src/app/owned_transcript_input_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_input_tests.rs @@ -200,17 +200,18 @@ fn complete_plan_turn(app: &mut App) { thread_id: thread_id.clone(), turn: turn.clone(), }), - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id.clone(), - turn_id: turn.id.clone(), - completed_at_ms: 0, - item: ThreadItem::Plan { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id.clone(), + turn.id.clone(), + ThreadItemPayload::Plan { id: "plan".into(), text: (1..=40) .map(|row| format!("- Plan step {row:02}\n")) .collect(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id, turn: Turn { diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index cdbc8512fabe..ee932e5c268b 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -2,7 +2,7 @@ use crate::app_command::AppCommand; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use std::collections::HashMap; use std::collections::HashSet; @@ -255,15 +255,15 @@ impl PendingInteractiveReplayState { pub(super) fn note_server_notification(&mut self, notification: &ServerNotification) { match notification { - ServerNotification::ItemStarted(notification) => match ¬ification.item { - ThreadItem::CommandExecution { id, .. } => { + ServerNotification::ItemStarted(notification) => match ¬ification.item.payload { + ThreadItemPayload::CommandExecution { id, .. } => { self.exec_approval_call_ids.remove(id); Self::remove_call_id_from_turn_map( &mut self.exec_approval_call_ids_by_turn_id, id, ); } - ThreadItem::FileChange { id, .. } => { + ThreadItemPayload::FileChange { id, .. } => { self.patch_approval_call_ids.remove(id); Self::remove_call_id_from_turn_map( &mut self.patch_approval_call_ids_by_turn_id, diff --git a/codex-rs/tui/src/app/realtime_delivery.rs b/codex-rs/tui/src/app/realtime_delivery.rs index 723ea7e9ba3e..16e1b867cb7f 100644 --- a/codex-rs/tui/src/app/realtime_delivery.rs +++ b/codex-rs/tui/src/app/realtime_delivery.rs @@ -17,13 +17,13 @@ fn normalized_replay_text(text: &str) -> String { } fn record_voice_item( - item: &ThreadItem, + item: &ThreadItemPayload, turn_id: &str, delegated: &mut HashSet, items: &mut HashMap<(String, String, &'static str), String>, ) { match item { - ThreadItem::UserMessage { id, content, .. } => { + ThreadItemPayload::UserMessage { id, content, .. } => { if let Some(input) = crate::chatwidget::realtime_delegation_input(content) { delegated.insert(turn_id.to_string()); let display = crate::chatwidget::realtime_delegation_display_text(input); @@ -33,7 +33,7 @@ fn record_voice_item( ); } } - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id, text, phase: Some(MessagePhase::FinalAnswer) | None, @@ -55,7 +55,7 @@ pub(super) fn replayed_voice_texts_from_turns(turns: &[Turn]) -> ReplayedVoiceTe let mut delegated = HashSet::new(); let mut items = HashMap::new(); for turn in turns { - for item in &turn.items { + for item in turn.item_payloads() { record_voice_item(item, &turn.id, &mut delegated, &mut items); } } @@ -82,7 +82,7 @@ pub(super) fn replayed_voice_texts(snapshot: &ThreadEventSnapshot) -> ReplayedVo .collect::>(); let mut items = HashMap::new(); for turn in &snapshot.turns { - for item in &turn.items { + for item in turn.item_payloads() { record_voice_item(item, &turn.id, &mut delegated, &mut items); } } @@ -92,20 +92,20 @@ pub(super) fn replayed_voice_texts(snapshot: &ThreadEventSnapshot) -> ReplayedVo }; match notification.as_ref() { ServerNotification::ItemStarted(n) => { - if let ThreadItem::UserMessage { content, .. } = &n.item + if let ThreadItemPayload::UserMessage { content, .. } = &n.item.payload && crate::chatwidget::realtime_delegation_input(content).is_some() { delegated.insert(n.turn_id.clone()); } } ServerNotification::ItemCompleted(n) => { - record_voice_item(&n.item, &n.turn_id, &mut delegated, &mut items); + record_voice_item(&n.item.payload, &n.turn_id, &mut delegated, &mut items); } ServerNotification::TurnCompleted(n) if n.turn.status == TurnStatus::Completed => { - if let Some(item) = n.turn.items.iter().rev().find(|item| { + if let Some(item) = n.turn.item_payloads().rev().find(|item| { matches!( item, - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { phase: Some(MessagePhase::FinalAnswer) | None, .. } @@ -349,7 +349,7 @@ impl App { return; }; for (turn_id, item) in pending { - let ThreadItem::AgentMessage { id, text, .. } = &item else { + let ThreadItemPayload::AgentMessage { id, text, .. } = &item else { continue; }; // A matching ID with empty text did not render the saved answer. @@ -386,7 +386,8 @@ pub(super) fn completed_agent_items( }; match notification.as_ref() { ServerNotification::ItemCompleted(completed_item) => { - if let ThreadItem::AgentMessage { id, text, .. } = &completed_item.item + if let ThreadItemPayload::AgentMessage { id, text, .. } = + &completed_item.item.payload && !text.trim().is_empty() { completed.insert((completed_item.turn_id.clone(), id.clone()), text.clone()); @@ -395,8 +396,8 @@ pub(super) fn completed_agent_items( ServerNotification::TurnCompleted(completed_turn) if completed_turn.turn.status == TurnStatus::Completed => { - for item in &completed_turn.turn.items { - if let ThreadItem::AgentMessage { id, text, .. } = item + for item in completed_turn.turn.item_payloads() { + if let ThreadItemPayload::AgentMessage { id, text, .. } = item && !text.trim().is_empty() { completed @@ -415,8 +416,8 @@ pub(super) fn completed_agent_items_from_turns( ) -> HashMap<(String, String), String> { let mut completed = HashMap::new(); for turn in turns { - for item in &turn.items { - if let ThreadItem::AgentMessage { id, text, .. } = item + for item in turn.item_payloads() { + if let ThreadItemPayload::AgentMessage { id, text, .. } = item && !text.trim().is_empty() { completed.insert((turn.id.clone(), id.clone()), text.clone()); diff --git a/codex-rs/tui/src/app/reasoning_replay.rs b/codex-rs/tui/src/app/reasoning_replay.rs index 84ae583873ec..07e02d87cac1 100644 --- a/codex-rs/tui/src/app/reasoning_replay.rs +++ b/codex-rs/tui/src/app/reasoning_replay.rs @@ -8,7 +8,7 @@ use super::ThreadEventSnapshot; use crate::chatwidget::ChatWidget; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnStatus; pub(super) struct ReasoningReplay { @@ -25,10 +25,10 @@ impl ReasoningReplay { .iter_mut() .find(|turn| turn.id == started.turn_id && turn.status == TurnStatus::InProgress)?; let index = turn.items.iter().position( - |item| matches!(item, ThreadItem::Reasoning { id, .. } if id == started.item.id()), + |item| matches!(&item.payload, ThreadItemPayload::Reasoning { id, .. } if id == started.item.id()), )?; - match turn.items.remove(index) { - ThreadItem::Reasoning { + match turn.items.remove(index).payload { + ThreadItemPayload::Reasoning { summary, content, .. } => Some((summary, content)), _ => unreachable!("matched a reasoning item"), diff --git a/codex-rs/tui/src/app/replay_filter.rs b/codex-rs/tui/src/app/replay_filter.rs index 01b0c6a415ec..0058e808cc16 100644 --- a/codex-rs/tui/src/app/replay_filter.rs +++ b/codex-rs/tui/src/app/replay_filter.rs @@ -3,7 +3,7 @@ use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use std::collections::HashSet; use super::ThreadBufferedEvent; @@ -67,7 +67,7 @@ pub(super) fn omit_completed_agent_deltas(events: &mut Vec) if let ThreadBufferedEvent::Notification(notification) = event { match notification.as_ref() { ServerNotification::ItemCompleted(notification) => { - if let ThreadItem::AgentMessage { id, .. } = ¬ification.item { + if let ThreadItemPayload::AgentMessage { id, .. } = ¬ification.item.payload { completed.insert(( notification.thread_id.clone(), notification.turn_id.clone(), diff --git a/codex-rs/tui/src/app/safety_buffering.rs b/codex-rs/tui/src/app/safety_buffering.rs index 9c14dde984d5..72da56bfcd64 100644 --- a/codex-rs/tui/src/app/safety_buffering.rs +++ b/codex-rs/tui/src/app/safety_buffering.rs @@ -10,6 +10,7 @@ use crate::chatwidget::ThreadInputState; use crate::chatwidget::ThreadInputStateRestoreMode; use crate::chatwidget::UserMessage; use codex_app_server_protocol::ThreadHistoryMode; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::UserInput; @@ -153,7 +154,7 @@ impl App { .data .into_iter() .rev() - .map(|entry| entry.item) + .map(ThreadItem::from) .collect(); turn.items_view = TurnItemsView::Full; } @@ -178,9 +179,9 @@ impl App { .iter() .find(|turn| turn.id == turn_id) .into_iter() - .flat_map(|turn| &turn.items) + .flat_map(Turn::item_payloads) .filter_map(|item| match item { - ThreadItem::UserMessage { content, .. } => Some(content), + ThreadItemPayload::UserMessage { content, .. } => Some(content), _ => None, }) .skip(/*n*/ 1) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c0c378b16d2a..9e5b8315da04 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -126,6 +126,7 @@ use crate::app_backtrack::nth_user_position; use crate::app_backtrack::user_count; use crate::app_event::HistoryBatchEntryResponse; use crate::test_support::boxed_future; +use codex_app_server_protocol::ThreadItem; async fn drain_managed_worktree_start(app: &mut App, server: &mut AppServerSession) { if let Some((mode, name)) = app.pending_start_managed_worktree.take() { @@ -190,7 +191,7 @@ use codex_app_server_protocol::ThreadArchivedNotification; use codex_app_server_protocol::ThreadAttachmentOperation; use codex_app_server_protocol::ThreadAttachmentUpdatedNotification; use codex_app_server_protocol::ThreadClosedNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStartedNotification; @@ -916,7 +917,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm vec![test_turn( "turn-1", TurnStatus::Completed, - vec![ThreadItem::UserMessage { + vec![ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { @@ -1847,11 +1848,10 @@ async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() turns: Vec::new(), events: vec![ ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-1".into(), - completed_at_ms: 0, - item: ThreadItem::UserMessage { + codex_app_server_protocol::ItemCompletedNotification::new( + thread_id.to_string(), + "turn-1".into(), + ThreadItemPayload::UserMessage { id: "accepted".into(), client_id: Some(client_id), content: vec![UserInput::Text { @@ -1859,7 +1859,9 @@ async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() text_elements: Vec::new(), }], }, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), ))), ThreadBufferedEvent::Notification(Box::new(turn_completed_notification( thread_id, @@ -2202,11 +2204,10 @@ async fn collab_receiver_notification_caches_thread_without_app_server_read() { ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: ThreadId::new().to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: ThreadItem::CollabAgentToolCall { + ServerNotification::ItemStarted(ItemStartedNotification::new( + ThreadId::new().to_string(), + "turn-1".to_string(), + ThreadItemPayload::CollabAgentToolCall { id: "wait-1".to_string(), tool: codex_app_server_protocol::CollabAgentTool::Wait, status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress, @@ -2217,7 +2218,8 @@ async fn collab_receiver_notification_caches_thread_without_app_server_read() { reasoning_effort: None, agents_states: HashMap::new(), }, - }), + /*started_at_ms*/ 0, + )), ))); assert_eq!( @@ -2239,28 +2241,31 @@ async fn collab_receiver_notification_does_not_cache_not_found_thread() { ThreadId::from_string("00000000-0000-0000-0000-000000000124").expect("valid thread id"); app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( - ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { - thread_id: ThreadId::new().to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: ThreadItem::CollabAgentToolCall { - id: "send-1".to_string(), - tool: codex_app_server_protocol::CollabAgentTool::SendInput, - status: codex_app_server_protocol::CollabAgentToolCallStatus::Failed, - sender_thread_id: ThreadId::new().to_string(), - receiver_thread_ids: vec![receiver_thread_id.to_string()], - prompt: Some("hello".to_string()), - model: None, - reasoning_effort: None, - agents_states: HashMap::from([( - receiver_thread_id.to_string(), - codex_app_server_protocol::CollabAgentState { - status: codex_app_server_protocol::CollabAgentStatus::NotFound, - message: None, - }, - )]), - }, - }), + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + ThreadId::new().to_string(), + "turn-1".to_string(), + ThreadItemPayload::CollabAgentToolCall { + id: "send-1".to_string(), + tool: codex_app_server_protocol::CollabAgentTool::SendInput, + status: codex_app_server_protocol::CollabAgentToolCallStatus::Failed, + sender_thread_id: ThreadId::new().to_string(), + receiver_thread_ids: vec![receiver_thread_id.to_string()], + prompt: Some("hello".to_string()), + model: None, + reasoning_effort: None, + agents_states: HashMap::from([( + receiver_thread_id.to_string(), + codex_app_server_protocol::CollabAgentState { + status: codex_app_server_protocol::CollabAgentStatus::NotFound, + message: None, + }, + )]), + }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), ))); assert_eq!(app.agent_navigation.get(&receiver_thread_id), None); @@ -4498,11 +4503,10 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { let thread_id = ThreadId::new(); app.enqueue_thread_notification( thread_id, - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-approval".to_string(), - started_at_ms: 0, - item: ThreadItem::FileChange { + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id.to_string(), + "turn-approval".to_string(), + ThreadItemPayload::FileChange { id: "patch-approval".to_string(), changes: vec![FileUpdateChange { path: "README.md".to_string(), @@ -4511,7 +4515,8 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { }], status: codex_app_server_protocol::PatchApplyStatus::InProgress, }, - }), + /*started_at_ms*/ 0, + )), ) .await .expect("enqueue file change item"); @@ -4576,11 +4581,10 @@ async fn active_thread_file_change_approval_recovers_buffered_changes() { app.startup_protected_input_boundary = true; app.enqueue_thread_notification( thread_id, - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-active-approval".to_string(), - started_at_ms: 0, - item: ThreadItem::FileChange { + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id.to_string(), + "turn-active-approval".to_string(), + ThreadItemPayload::FileChange { id: "patch-active-approval".to_string(), changes: vec![FileUpdateChange { path: "visible-target.md".to_string(), @@ -4589,7 +4593,8 @@ async fn active_thread_file_change_approval_recovers_buffered_changes() { }], status: codex_app_server_protocol::PatchApplyStatus::InProgress, }, - }), + /*started_at_ms*/ 0, + )), ) .await .expect("enqueue file change item"); @@ -4626,19 +4631,22 @@ async fn active_thread_file_change_approval_recovers_buffered_changes() { assert!(!app.chat_widget.has_active_view()); assert!(app.startup_pending_protected_request); app.chat_widget.handle_server_notification( - ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn-active-approval".to_string(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "streaming".to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }, - }), + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + thread_id.to_string(), + "turn-active-approval".to_string(), + ThreadItemPayload::AgentMessage { + id: "agent-1".to_string(), + text: "streaming".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), /*replay_kind*/ None, ); @@ -4661,7 +4669,7 @@ async fn replayed_file_change_approval_recovers_snapshot_changes() { turns: vec![test_turn( "turn-replayed-approval", TurnStatus::InProgress, - vec![ThreadItem::FileChange { + vec![ThreadItemPayload::FileChange { id: "patch-replayed-approval".to_string(), changes: vec![FileUpdateChange { path: "visible-target.md".to_string(), @@ -5591,7 +5599,7 @@ async fn side_thread_snapshot_hides_forked_parent_transcript() { let parent_turn = test_turn( "parent-turn", TurnStatus::Completed, - vec![ThreadItem::UserMessage { + vec![ThreadItemPayload::UserMessage { id: "parent-user".to_string(), client_id: None, content: vec![AppServerUserInput::Text { @@ -6913,7 +6921,7 @@ async fn app_server_thread_replacement_clears_previous_transcript_before_replay( turns: vec![test_turn( "next-turn", TurnStatus::Completed, - vec![ThreadItem::UserMessage { + vec![ThreadItemPayload::UserMessage { id: "next-user".to_string(), client_id: None, content: vec![AppServerUserInput::Text { @@ -6997,7 +7005,7 @@ async fn snapshot_thread_switch_discards_queued_previous_history() -> Result<()> turns: vec![test_turn( "next-turn", TurnStatus::Completed, - vec![ThreadItem::UserMessage { + vec![ThreadItemPayload::UserMessage { id: "next-user".to_string(), client_id: None, content: vec![AppServerUserInput::Text { @@ -7157,17 +7165,20 @@ async fn copy_picker_opening_preserves_terminal_scrollback_without_reflow() { crate::transcript_mode::TranscriptMode::Terminal; let response = "Existing response\n\n```rust\nkeep_scrollback();\n```"; app.chat_widget.handle_server_notification( - ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { - thread_id: String::new(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: serde_json::from_value(serde_json::json!({ - "type": "agentMessage", - "id": "message-1", - "text": response, - })) - .expect("valid completed agent message"), - }), + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + String::new(), + "turn-1".to_string(), + serde_json::from_value(serde_json::json!({ + "type": "agentMessage", + "id": "message-1", + "text": response, + })) + .expect("valid completed agent message"), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), /*replay_kind*/ None, ); while app_event_rx.try_recv().is_ok() {} @@ -7498,12 +7509,12 @@ async fn resizing_empty_transcript_schedules_settled_size_recheck() { ); } -fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { +fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), status, error: None, started_at: None, @@ -8774,12 +8785,13 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place_scenario )); for item in selected_turn.items { store.push_notification(ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - thread_id: source_thread_id.to_string(), - turn_id: selected_turn.id.clone(), - completed_at_ms: 0, - item, - }, + codex_app_server_protocol::ItemCompletedNotification::new( + source_thread_id.to_string(), + selected_turn.id.clone(), + item.payload, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); } store.push_notification(turn_completed_notification( @@ -9112,14 +9124,14 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::UserMessage { + items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), }], - }], + })], status: TurnStatus::Completed, error: None, started_at: None, @@ -9131,22 +9143,22 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ - ThreadItem::UserMessage { + ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "user-2".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "third prompt".to_string(), text_elements: Vec::new(), }], - }, - ThreadItem::AgentMessage { + }), + ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: "assistant-2".to_string(), text: "done".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, - }, + }), ], status: TurnStatus::Completed, error: None, @@ -9227,11 +9239,10 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { turns: Vec::new(), events: vec![ThreadBufferedEvent::Notification(Box::new( ServerNotification::ItemStarted( - codex_app_server_protocol::ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: ThreadItem::CollabAgentToolCall { + codex_app_server_protocol::ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + ThreadItemPayload::CollabAgentToolCall { id: "wait-1".to_string(), tool: codex_app_server_protocol::CollabAgentTool::Wait, status: @@ -9243,7 +9254,8 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { reasoning_effort: None, agents_states: HashMap::new(), }, - }, + /*started_at_ms*/ 0, + ), ), ))], active_reasoning_item: None, @@ -9283,7 +9295,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { let resumed_turns = vec![test_turn( "turn-1", TurnStatus::Completed, - vec![ThreadItem::UserMessage { + vec![ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index f102e25b47ab..e294f284f28f 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -128,7 +128,7 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re "id": id, "sessionId": id, "preview": "only once", "ephemeral": false, "modelProvider": "test-provider", "createdAt": 1, "updatedAt": 2, "status": {"type": "idle"}, "cwd": cwd, "cliVersion": "0.0.0", "source": "cli", - "turns": [test_turn("accepted", TurnStatus::Completed, vec![ThreadItem::UserMessage { + "turns": [test_turn("accepted", TurnStatus::Completed, vec![ThreadItemPayload::UserMessage { id: "input-1".into(), client_id: None, content: vec![UserInput::Text { text: "only once".into(), text_elements: Vec::new() }], }])] }); diff --git a/codex-rs/tui/src/app/tests/browsing_pagination_tests.rs b/codex-rs/tui/src/app/tests/browsing_pagination_tests.rs index a52491902b55..2a3f602117e8 100644 --- a/codex-rs/tui/src/app/tests/browsing_pagination_tests.rs +++ b/codex-rs/tui/src/app/tests/browsing_pagination_tests.rs @@ -38,7 +38,7 @@ async fn browsing_loads_before_the_oldest_prompt_after_a_partial_answer() -> Res .extend(crate::thread_transcript::thread_items_to_transcript_cells( Some(thread_id), &app.config.cwd, - started.turns.iter().flat_map(|turn| turn.items.clone()), + started.turns.iter().flat_map(Turn::item_payloads).cloned(), crate::thread_transcript::RawReasoningVisibility::Hidden, Some(&app.config), )); diff --git a/codex-rs/tui/src/app/tests/buffered_replay.rs b/codex-rs/tui/src/app/tests/buffered_replay.rs index 47ea84e91ddf..2c122f804280 100644 --- a/codex-rs/tui/src/app/tests/buffered_replay.rs +++ b/codex-rs/tui/src/app/tests/buffered_replay.rs @@ -6,6 +6,7 @@ use crate::chatwidget::tests::helpers::render_bottom_popup; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; +use codex_app_server_protocol::ThreadItem; use pretty_assertions::assert_eq; fn delta(thread: &str, turn: &str, item: &str) -> ServerNotification { @@ -18,11 +19,10 @@ fn delta(thread: &str, turn: &str, item: &str) -> ServerNotification { } fn completed(thread: &str) -> ServerNotification { - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread.into(), - turn_id: "turn".into(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread.into(), + "turn".into(), + ThreadItemPayload::AgentMessage { id: "answer".into(), text: "Final **answer**".into(), phase: None, @@ -30,7 +30,9 @@ fn completed(thread: &str) -> ServerNotification { delivery: None, questions: None, }, - }) + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )) } #[tokio::test] @@ -50,16 +52,18 @@ async fn refreshed_active_reasoning_accepts_later_deltas_and_complete_summary() ThreadEventChannel::new_with_session(capacity, session.clone(), vec![turn.clone()]); { let mut store = channel.store.lock().await; - store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: turn.id.clone(), - item: ThreadItem::Reasoning { - id: "reasoning".into(), - summary: Vec::new(), - content: Vec::new(), - }, - started_at_ms: 0, - })); + store.push_notification(ServerNotification::ItemStarted( + ItemStartedNotification::new( + thread_id.to_string(), + turn.id.clone(), + ThreadItemPayload::Reasoning { + id: "reasoning".into(), + summary: Vec::new(), + content: Vec::new(), + }, + /*started_at_ms*/ 0, + ), + )); store.push_notification(ServerNotification::ReasoningSummaryTextDelta( ReasoningSummaryTextDeltaNotification { thread_id: thread_id.to_string(), @@ -71,29 +75,25 @@ async fn refreshed_active_reasoning_accepts_later_deltas_and_complete_summary() )); assert_eq!(store.buffer.len(), capacity.min(/*other*/ 2)); if voice_handoff { - store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: turn.id.clone(), - item: ThreadItem::UserMessage { + store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification::new(thread_id.to_string(), turn.id.clone(), ThreadItemPayload::UserMessage { id: "voice-handoff".into(), client_id: None, content: vec![AppServerUserInput::Text { text: "spoken follow-up".into(), text_elements: Vec::new(), }], - }, - started_at_ms: 0, - })); + }, /*started_at_ms*/ 0))); } } let mut snapshot = channel.store.lock().await.snapshot(); app.thread_event_channels.insert(thread_id, channel); if refreshed_item_is_partial { - turn.items.push(ThreadItem::Reasoning { - id: "reasoning".into(), - summary: vec!["**First heading**\nOriginal analysis paragraph".into()], - content: Vec::new(), - }); + turn.items + .push(ThreadItem::untimed(ThreadItemPayload::Reasoning { + id: "reasoning".into(), + summary: vec!["**First heading**\nOriginal analysis paragraph".into()], + content: Vec::new(), + })); } if refresh { app.apply_refreshed_snapshot_thread( @@ -122,19 +122,14 @@ async fn refreshed_active_reasoning_accepts_later_deltas_and_complete_summary() /*replay_kind*/ None, ); assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Final paragraph")); - let completed = ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "reasoning-turn".into(), - item: ThreadItem::Reasoning { + let completed = ServerNotification::ItemCompleted(ItemCompletedNotification::new(thread_id.to_string(), "reasoning-turn".into(), ThreadItemPayload::Reasoning { id: "reasoning".into(), summary: vec![ "**First heading**\nOriginal analysis paragraph\n**Running checks**\nFinal paragraph" .into(), ], content: Vec::new(), - }, - completed_at_ms: 0, - }); + }, /*started_at_ms*/ None, /*completed_at_ms*/ 0)); let store = &app.thread_event_channels[&thread_id].store; store.lock().await.push_notification_ref(&completed); assert!( @@ -224,7 +219,7 @@ fn buffered_replay_keeps_unfinished_and_unrelated_deltas_in_order() { #[test] fn evicted_voice_delegation_marker_still_suppresses_private_replay() { let mut store = ThreadEventStore::new(/*capacity*/ 2); - let voice_request = ThreadItem::UserMessage { + let voice_request = ThreadItemPayload::UserMessage { id: "user".into(), client_id: None, content: vec![codex_app_server_protocol::UserInput::Text { @@ -233,19 +228,18 @@ fn evicted_voice_delegation_marker_still_suppresses_private_replay() { }], }; store.push_notification(ServerNotification::ItemStarted( - codex_app_server_protocol::ItemStartedNotification { - thread_id: "thread".into(), - turn_id: "voice-turn".into(), - started_at_ms: 0, - item: voice_request.clone(), - }, + codex_app_server_protocol::ItemStartedNotification::new( + "thread".into(), + "voice-turn".into(), + voice_request.clone(), + /*started_at_ms*/ 0, + ), )); store.push_notification(delta("thread", "voice-turn", "reasoning")); - let private = ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread".into(), - turn_id: "voice-turn".into(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { + let private = ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread".into(), + "voice-turn".into(), + ThreadItemPayload::AgentMessage { id: "reasoning".into(), text: "[ANALYSIS] private".into(), phase: None, @@ -253,7 +247,9 @@ fn evicted_voice_delegation_marker_still_suppresses_private_replay() { delivery: None, questions: None, }, - }); + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )); store.push_notification(private.clone()); assert!(store.snapshot().events.is_empty()); store.push_notification(delta("thread", "voice-turn", "unfinished")); @@ -286,16 +282,17 @@ fn evicted_voice_delegation_marker_still_suppresses_private_replay() { content_index: 0, }, ), - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread".into(), - turn_id: "voice-turn".into(), - completed_at_ms: 0, - item: ThreadItem::Reasoning { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread".into(), + "voice-turn".into(), + ThreadItemPayload::Reasoning { id: "reasoning".into(), summary: vec!["private summary".into()], content: vec!["private raw reasoning".into()], }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), ] { store.push_notification(notification); assert!(store.snapshot().events.is_empty()); @@ -305,7 +302,7 @@ fn evicted_voice_delegation_marker_still_suppresses_private_replay() { resumed.set_turns(vec![Turn { id: "voice-turn".into(), root_turn_id: None, - items: vec![voice_request], + items: vec![ThreadItem::untimed(voice_request)], items_view: codex_app_server_protocol::TurnItemsView::Summary, status: TurnStatus::InProgress, error: None, @@ -331,12 +328,12 @@ fn evicted_voice_delegation_marker_still_suppresses_private_replay() { #[test] fn saved_voice_turn_suppresses_reasoning_without_hiding_typed_reasoning() { - let reasoning = ThreadItem::Reasoning { + let reasoning = ThreadItemPayload::Reasoning { id: "reasoning".into(), summary: vec!["private summary".into()], content: vec!["private raw reasoning".into()], }; - let voice_request = ThreadItem::UserMessage { + let voice_request = ThreadItemPayload::UserMessage { id: "voice-request".into(), client_id: None, content: vec![codex_app_server_protocol::UserInput::Text { @@ -357,21 +354,27 @@ fn saved_voice_turn_suppresses_reasoning_without_hiding_typed_reasoning() { }; let mut store = ThreadEventStore::new(/*capacity*/ 4); store.set_turns(vec![ - turn("voice", vec![voice_request.clone(), reasoning.clone()]), - turn("typed", vec![reasoning.clone()]), + turn( + "voice", + vec![ + ThreadItem::untimed(voice_request.clone()), + ThreadItem::untimed(reasoning.clone()), + ], + ), + turn("typed", vec![ThreadItem::untimed(reasoning.clone())]), ]); assert_eq!( store.snapshot().turns, vec![ - turn("voice", vec![voice_request]), - turn("typed", vec![reasoning]) + turn("voice", vec![ThreadItem::untimed(voice_request)]), + turn("typed", vec![ThreadItem::untimed(reasoning)]) ] ); } #[test] fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() { - let commentary = |id: &str, text: &str| ThreadItem::AgentMessage { + let commentary = |id: &str, text: &str| ThreadItemPayload::AgentMessage { id: id.into(), text: text.into(), phase: Some(codex_protocol::models::MessagePhase::Commentary), @@ -379,7 +382,7 @@ fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() { memory_citation: None, delivery: None, }; - let marker = ThreadItem::UserMessage { + let marker = ThreadItemPayload::UserMessage { id: "voice".into(), client_id: None, content: vec![codex_app_server_protocol::UserInput::Text { @@ -393,7 +396,11 @@ fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() { store.set_turns(vec![Turn { id: "shared".into(), root_turn_id: None, - items: vec![typed.clone(), marker.clone(), private.clone()], + items: vec![ + ThreadItem::untimed(typed.clone()), + ThreadItem::untimed(marker.clone()), + ThreadItem::untimed(private.clone()), + ], items_view: codex_app_server_protocol::TurnItemsView::Full, status: TurnStatus::InProgress, error: None, @@ -403,33 +410,44 @@ fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() { }]); for item in [typed.clone(), marker.clone(), private] { store.push_notification(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread".into(), - turn_id: "shared".into(), - completed_at_ms: 0, + ItemCompletedNotification::new( + "thread".into(), + "shared".into(), item, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); } let snapshot = store.snapshot(); - assert_eq!(snapshot.turns[0].items, vec![typed.clone(), marker]); + assert_eq!( + snapshot.turns[0] + .items + .iter() + .map(|entry| &entry.payload) + .collect::>(), + [&typed, &marker] + ); let completed = snapshot .events .into_iter() .filter_map(|event| match event { ThreadBufferedEvent::Notification(notification) => match *notification { - ServerNotification::ItemCompleted(item) => Some(item.item), + ServerNotification::ItemCompleted(item) => Some(item.item.payload), _ => None, }, _ => None, }) .collect::>(); - assert_eq!(completed, vec![typed, snapshot.turns[0].items[1].clone()]); + assert_eq!( + completed, + vec![typed, snapshot.turns[0].items[1].payload.clone()] + ); } #[test] fn snapshot_keeps_typed_item_completed_after_voice_handoff() { - let typed = ThreadItem::AgentMessage { + let typed = ThreadItemPayload::AgentMessage { id: "typed".into(), text: "Typed commentary completed late".into(), phase: Some(codex_protocol::models::MessagePhase::Commentary), @@ -437,7 +455,7 @@ fn snapshot_keeps_typed_item_completed_after_voice_handoff() { memory_citation: None, delivery: None, }; - let private = ThreadItem::AgentMessage { + let private = ThreadItemPayload::AgentMessage { id: "private".into(), text: "Voice-private commentary".into(), phase: Some(codex_protocol::models::MessagePhase::Commentary), @@ -445,7 +463,7 @@ fn snapshot_keeps_typed_item_completed_after_voice_handoff() { memory_citation: None, delivery: None, }; - let marker = ThreadItem::UserMessage { + let marker = ThreadItemPayload::UserMessage { id: "voice".into(), client_id: None, content: vec![codex_app_server_protocol::UserInput::Text { @@ -456,22 +474,23 @@ fn snapshot_keeps_typed_item_completed_after_voice_handoff() { let mut store = ThreadEventStore::new(/*capacity*/ 8); for item in [typed.clone(), marker, private.clone()] { store.push_notification(ServerNotification::ItemStarted( - codex_app_server_protocol::ItemStartedNotification { - thread_id: "thread".into(), - turn_id: "shared".into(), - started_at_ms: 0, + codex_app_server_protocol::ItemStartedNotification::new( + "thread".into(), + "shared".into(), item, - }, + /*started_at_ms*/ 0, + ), )); } for item in [typed.clone(), private] { store.push_notification(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: "thread".into(), - turn_id: "shared".into(), - completed_at_ms: 0, + ItemCompletedNotification::new( + "thread".into(), + "shared".into(), item, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); } let completed = store @@ -480,7 +499,7 @@ fn snapshot_keeps_typed_item_completed_after_voice_handoff() { .into_iter() .filter_map(|event| match event { ThreadBufferedEvent::Notification(notification) => match *notification { - ServerNotification::ItemCompleted(n) => Some(n.item), + ServerNotification::ItemCompleted(n) => Some(n.item.payload), _ => None, }, _ => None, @@ -494,11 +513,10 @@ async fn evicted_voice_marker_survives_widget_snapshot_for_late_reasoning() { let thread_id = ThreadId::new(); let mut store = ThreadEventStore::new(/*capacity*/ 1); store.push_notification(ServerNotification::ItemStarted( - codex_app_server_protocol::ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: "voice-turn".into(), - started_at_ms: 0, - item: ThreadItem::UserMessage { + codex_app_server_protocol::ItemStartedNotification::new( + thread_id.to_string(), + "voice-turn".into(), + ThreadItemPayload::UserMessage { id: "user".into(), client_id: None, content: vec![codex_app_server_protocol::UserInput::Text { @@ -507,7 +525,8 @@ async fn evicted_voice_marker_survives_widget_snapshot_for_late_reasoning() { text_elements: Vec::new(), }], }, - }, + /*started_at_ms*/ 0, + ), )); store.push_notification(delta(&thread_id.to_string(), "voice-turn", "private")); let snapshot = store.snapshot(); @@ -534,16 +553,17 @@ async fn evicted_voice_marker_survives_widget_snapshot_for_late_reasoning() { /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "voice-turn".into(), - completed_at_ms: 0, - item: ThreadItem::Reasoning { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id.to_string(), + "voice-turn".into(), + ThreadItemPayload::Reasoning { id: "late-reasoning".into(), summary: vec!["private after switch".into()], content: Vec::new(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( @@ -551,11 +571,10 @@ async fn evicted_voice_marker_survives_widget_snapshot_for_late_reasoning() { /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "voice-turn".into(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id.to_string(), + "voice-turn".into(), + ThreadItemPayload::AgentMessage { id: "private-commentary".into(), text: "[COMMENTARY] private after switch".into(), phase: None, @@ -563,7 +582,9 @@ async fn evicted_voice_marker_survives_widget_snapshot_for_late_reasoning() { delivery: None, questions: None, }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); while let Ok(event) = events.try_recv() { @@ -649,7 +670,7 @@ async fn misalignment_buffered_replay_preserves_input_after_continuation() { }; let failed_turn = Turn { error: Some(error.clone()), - items: vec![ThreadItem::McpToolCall { + items: vec![ThreadItem::untimed(ThreadItemPayload::McpToolCall { id: "tool-call".into(), server: "sample".into(), tool: "inspect".into(), @@ -663,7 +684,7 @@ async fn misalignment_buffered_replay_preserves_input_after_continuation() { result: None, error: None, duration_ms: None, - }], + })], ..test_turn("failed-turn", TurnStatus::Failed, Vec::new()) }; let continued_turn = test_turn("continued-turn", TurnStatus::Completed, Vec::new()); diff --git a/codex-rs/tui/src/app/tests/history_hydration_tests.rs b/codex-rs/tui/src/app/tests/history_hydration_tests.rs index 9fd420d28506..ecc4ba35e488 100644 --- a/codex-rs/tui/src/app/tests/history_hydration_tests.rs +++ b/codex-rs/tui/src/app/tests/history_hydration_tests.rs @@ -156,7 +156,7 @@ async fn history_hydration_metadata_tracks_missing_turns_and_stale_completions_k .turns .iter() .flat_map(|turn| &turn.items) - .map(ThreadItem::id) + .map(codex_app_server_protocol::ThreadItem::id) .collect::>(), vec!["item-12-0"] ); @@ -175,7 +175,7 @@ async fn history_hydration_metadata_tracks_missing_turns_and_stale_completions_k .apply_older_history_page(target.thread_id, &old_cursor, page, &mut thread.turns) .await?; assert_eq!( - items.iter().map(ThreadItem::id).collect::>(), + items.iter().map(ThreadItemPayload::id).collect::>(), vec!["item-3-0", "item-5-0"] ); assert_eq!( @@ -183,7 +183,7 @@ async fn history_hydration_metadata_tracks_missing_turns_and_stale_completions_k .turns .iter() .flat_map(|turn| &turn.items) - .map(ThreadItem::id) + .map(codex_app_server_protocol::ThreadItem::id) .collect::>(), vec!["item-3-0", "item-5-0", "item-12-0"] ); diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 5f8a8e538147..6125e9402873 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -60,7 +60,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv vec![test_turn( "cached", TurnStatus::Completed, - vec![ThreadItem::Plan { + vec![ThreadItemPayload::Plan { id: "cached-plan".into(), text: "Cached previous conversation".into(), }], diff --git a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs index 7cfc88a95031..ec7d8b433ee0 100644 --- a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs +++ b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs @@ -211,7 +211,7 @@ async fn beginning_navigation_holds_the_view_until_the_last_page_arrives() -> Re app.transcript_cells = crate::thread_transcript::thread_items_to_transcript_cells( Some(thread_id), &app.config.cwd, - started.turns.iter().flat_map(|turn| turn.items.clone()), + started.turns.iter().flat_map(Turn::item_payloads).cloned(), crate::thread_transcript::RawReasoningVisibility::Hidden, Some(&app.config), ); @@ -331,7 +331,7 @@ async fn returning_to_latest_retains_pending_pages_without_continuing_to_the_beg app.transcript_cells = crate::thread_transcript::thread_items_to_transcript_cells( Some(thread_id), &app.config.cwd, - started.turns.iter().flat_map(|turn| turn.items.clone()), + started.turns.iter().flat_map(Turn::item_payloads).cloned(), crate::thread_transcript::RawReasoningVisibility::Hidden, Some(&app.config), ); diff --git a/codex-rs/tui/src/app/tests/patch_approval_tests.rs b/codex-rs/tui/src/app/tests/patch_approval_tests.rs index 8f5e36e1012e..c5dee234175c 100644 --- a/codex-rs/tui/src/app/tests/patch_approval_tests.rs +++ b/codex-rs/tui/src/app/tests/patch_approval_tests.rs @@ -55,8 +55,8 @@ fn expected_changes() -> HashMap { ]) } -fn patch_item() -> ThreadItem { - ThreadItem::FileChange { +fn patch_item() -> ThreadItemPayload { + ThreadItemPayload::FileChange { id: ITEM_ID.to_string(), changes: changes(), status: PatchApplyStatus::InProgress, @@ -83,12 +83,12 @@ async fn enqueue_pending_patch(app: &mut App, thread_id: ThreadId) -> Result<()> .await?; app.enqueue_thread_notification( thread_id, - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: TURN_ID.to_string(), - started_at_ms: 0, - item: patch_item(), - }), + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id.to_string(), + TURN_ID.to_string(), + patch_item(), + /*started_at_ms*/ 0, + )), ) .await?; let request = request(thread_id); @@ -261,19 +261,22 @@ async fn active_patch_approval_preserves_deferred_startup_protection() -> Result assert!(app.startup_pending_protected_request); app.chat_widget.handle_server_notification( - ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: TURN_ID.to_string(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "streaming".to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }, - }), + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + thread_id.to_string(), + TURN_ID.to_string(), + ThreadItemPayload::AgentMessage { + id: "agent-1".to_string(), + text: "streaming".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), /*replay_kind*/ None, ); assert_eq!(open_patch(&mut app, &mut rx).changes, expected_changes()); diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs index 2bda4f08bfdb..6a02e1d72033 100644 --- a/codex-rs/tui/src/app/tests/realtime_requests.rs +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnItemsView; @@ -45,8 +46,8 @@ fn voice_snapshot_directory_keeps_header_width_for_windows_paths() { ); } -fn test_agent_message(id: &str, text: &str) -> ThreadItem { - ThreadItem::AgentMessage { +fn test_agent_message(id: &str, text: &str) -> ThreadItemPayload { + ThreadItemPayload::AgentMessage { id: id.into(), text: text.into(), phase: Some(MessagePhase::FinalAnswer), @@ -56,8 +57,8 @@ fn test_agent_message(id: &str, text: &str) -> ThreadItem { } } -fn test_user_message(id: &str, text: &str) -> ThreadItem { - ThreadItem::UserMessage { +fn test_user_message(id: &str, text: &str) -> ThreadItemPayload { + ThreadItemPayload::UserMessage { id: id.into(), client_id: None, content: vec![UserInput::Text { @@ -300,7 +301,7 @@ async fn switching_agent_threads_preserves_backend_voice_and_routes_pending_spee turn: Turn { id: turn_id.into(), root_turn_id: None, - items: vec![answer.clone()], + items: vec![ThreadItem::untimed(answer.clone())], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, @@ -668,11 +669,11 @@ async fn replay_reconciles_only_matching_voice_captions_one_for_one() { id: "voice-turn".into(), root_turn_id: None, items: vec![ - test_user_message( + ThreadItem::untimed(test_user_message( "voice-user", "show <code> & tests", - ), - test_agent_message("voice-answer", "spoken answer"), + )), + ThreadItem::untimed(test_agent_message("voice-answer", "spoken answer")), ], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, @@ -683,7 +684,10 @@ async fn replay_reconciles_only_matching_voice_captions_one_for_one() { }; let typed = Turn { id: "typed-turn".into(), - items: vec![test_user_message("typed-user", "typed words")], + items: vec![ThreadItem::untimed(test_user_message( + "typed-user", + "typed words", + ))], ..voice.clone() }; app.replay_thread_snapshot( @@ -738,7 +742,7 @@ async fn retained_caption_consumes_only_one_matching_answer_fallback_on_reattach }] .into(), ); - let answer = |id: &str, text: &str| ThreadItem::AgentMessage { + let answer = |id: &str, text: &str| ThreadItemPayload::AgentMessage { id: id.into(), text: text.into(), phase: Some(MessagePhase::FinalAnswer), @@ -838,7 +842,7 @@ async fn buffered_voice_items_reconcile_captions_after_thread_switch() { .collect(), ); let items = [ - ThreadItem::UserMessage { + ThreadItemPayload::UserMessage { id: "user-item".into(), client_id: None, content: vec![UserInput::Text { @@ -853,12 +857,13 @@ async fn buffered_voice_items_reconcile_captions_after_thread_switch() { .into_iter() .map(|item| { ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { + ItemCompletedNotification::new( + source.to_string(), + "buffered-turn".into(), item, - thread_id: source.to_string(), - turn_id: "buffered-turn".into(), - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), ))) }) .collect(); @@ -917,7 +922,7 @@ async fn unrendered_buffered_items_do_not_consume_retained_captions() { }) .collect(), ); - let user_item = |id: &str, text: &str| ThreadItem::UserMessage { + let user_item = |id: &str, text: &str| ThreadItemPayload::UserMessage { id: id.into(), client_id: None, content: vec![UserInput::Text { @@ -925,14 +930,14 @@ async fn unrendered_buffered_items_do_not_consume_retained_captions() { text_elements: Vec::new(), }], }; - let completion = |turn_id: &str, status: TurnStatus, items: Vec| { + let completion = |turn_id: &str, status: TurnStatus, items: Vec| { ThreadBufferedEvent::Notification(Box::new(ServerNotification::TurnCompleted( TurnCompletedNotification { thread_id: source.to_string(), turn: Turn { id: turn_id.into(), root_turn_id: None, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), items_view: TurnItemsView::Summary, status, error: None, @@ -950,12 +955,12 @@ async fn unrendered_buffered_items_do_not_consume_retained_captions() { turns: Vec::new(), events: vec![ ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemStarted( - ItemStartedNotification { - item: user_item("started-user", "started only"), - thread_id: source.to_string(), - turn_id: "started".into(), - started_at_ms: 0, - }, + ItemStartedNotification::new( + source.to_string(), + "started".into(), + user_item("started-user", "started only"), + /*started_at_ms*/ 0, + ), ))), completion( "evicted", @@ -1064,7 +1069,10 @@ async fn completed_voice_caption_survives_repeated_thread_replacement() { let mut snapshot = empty_thread_snapshot(&app, source); snapshot.turns.push(Turn { id: "earlier-typed-turn".into(), - items: vec![test_agent_message("earlier-answer", "Earlier answer")], + items: vec![ThreadItem::untimed(test_agent_message( + "earlier-answer", + "Earlier answer", + ))], ..typed.clone() }); snapshot.turns.push(typed.clone()); @@ -1216,7 +1224,7 @@ async fn rejected_realtime_speech_restores_the_delegated_final_answer() -> Resul turn: Turn { id: "rejected-turn".to_string(), root_turn_id: None, - items: vec![answer], + items: vec![ThreadItem::untimed(answer)], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, @@ -1316,7 +1324,7 @@ async fn switching_threads_retains_undelivered_voice_answer_after_replay_evictio turn: Turn { id: "switched-turn".to_string(), root_turn_id: None, - items: vec![answer], + items: vec![ThreadItem::untimed(answer)], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, @@ -1385,7 +1393,7 @@ async fn switching_threads_retains_undelivered_voice_answer_after_replay_evictio assert_eq!(restored, 1); let replayed_item = test_agent_message("replayed-final", "Already in the thread snapshot."); let mut older_item = replayed_item.clone(); - if let ThreadItem::AgentMessage { text, .. } = &mut older_item { + if let ThreadItemPayload::AgentMessage { text, .. } = &mut older_item { *text = "Older pending text.".to_string(); } app.pending_realtime_speech_replay @@ -1397,7 +1405,7 @@ async fn switching_threads_retains_undelivered_voice_answer_after_replay_evictio turns: vec![Turn { id: "replayed-turn".to_string(), root_turn_id: None, - items: vec![replayed_item], + items: vec![ThreadItem::untimed(replayed_item)], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, @@ -1518,22 +1526,25 @@ fn send_item( app: &mut App, thread_id: ThreadId, turn_id: &str, - item: ThreadItem, + item: ThreadItemPayload, kind: ItemEventKind, ) { let notification = match kind { - ItemEventKind::Started => ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), + ItemEventKind::Started => ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id.to_string(), + turn_id.to_string(), item, - started_at_ms: 0, - }), - ItemEventKind::Completed => ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), - item, - completed_at_ms: 0, - }), + /*started_at_ms*/ 0, + )), + ItemEventKind::Completed => { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id.to_string(), + turn_id.to_string(), + item, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )) + } }; app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(notification))); } @@ -1600,7 +1611,7 @@ async fn delegated_final_speech_reaches_app_server_once_and_stale_speech_is_reje turn: Turn { id: turn_id.to_string(), root_turn_id: None, - items: vec![answer], + items: vec![ThreadItem::untimed(answer)], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, @@ -1678,7 +1689,7 @@ async fn delegated_final_speech_reaches_app_server_once_and_stale_speech_is_reje turn: Turn { id: second_turn.to_string(), root_turn_id: None, - items: vec![second_answer], + items: vec![ThreadItem::untimed(second_answer)], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, diff --git a/codex-rs/tui/src/app/tests/reasoning_resume_tests.rs b/codex-rs/tui/src/app/tests/reasoning_resume_tests.rs index 526a0ee8e093..54da8ce2352e 100644 --- a/codex-rs/tui/src/app/tests/reasoning_resume_tests.rs +++ b/codex-rs/tui/src/app/tests/reasoning_resume_tests.rs @@ -15,7 +15,7 @@ async fn resumed_reasoning_without_start_accepts_deltas_and_completion() { let session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); let items = snapshot_item_id .into_iter() - .map(|id| ThreadItem::Reasoning { + .map(|id| ThreadItemPayload::Reasoning { id: id.into(), summary: vec![ if id == "previous" { @@ -72,18 +72,19 @@ async fn resumed_reasoning_without_start_accepts_deltas_and_completion() { ); } app.chat_widget.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn".into(), - completed_at_ms: 0, - item: ThreadItem::Reasoning { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id.to_string(), + "turn".into(), + ThreadItemPayload::Reasoning { id: "reasoning".into(), summary: vec![ "**Inspecting**\nSnapshot paragraph.\nCompleted paragraph.".into(), ], content: Vec::new(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( @@ -126,7 +127,7 @@ async fn resumed_trailing_reasoning_is_kept_when_no_more_reasoning_arrives() { vec![test_turn( "turn", TurnStatus::InProgress, - vec![ThreadItem::Reasoning { + vec![ThreadItemPayload::Reasoning { id: "finished".into(), summary: vec!["**Inspecting**\nSaved paragraph.".into()], content: Vec::new(), @@ -137,16 +138,16 @@ async fn resumed_trailing_reasoning_is_kept_when_no_more_reasoning_arrives() { if next_item { app.chat_widget.handle_server_notification( ServerNotification::ItemStarted( - codex_app_server_protocol::ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: "turn".into(), - started_at_ms: 0, - item: ThreadItem::Reasoning { + codex_app_server_protocol::ItemStartedNotification::new( + thread_id.to_string(), + "turn".into(), + ThreadItemPayload::Reasoning { id: "next".into(), summary: Vec::new(), content: Vec::new(), }, - }, + /*started_at_ms*/ 0, + ), ), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index 1dcfae93704e..fcd201a8ebe5 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -317,9 +317,9 @@ stream_max_retries = 0 .expect("app-server remains open"); let committed_id = if let AppServerEvent::ServerNotification(notification) = &event.event && let ServerNotification::ItemCompleted(item) = notification.as_ref() - && let ThreadItem::UserMessage { + && let ThreadItemPayload::UserMessage { content, client_id, .. - } = &item.item + } = &item.item.payload && ChatWidget::user_message_display_from_inputs(content).message == COMMITTED_STEER { Some(client_id.clone().expect("receipt echoes the submission ID")) @@ -457,9 +457,9 @@ fn user_message_count(thread: &Thread, prompt: &str) -> usize { thread .turns .iter() - .flat_map(|turn| &turn.items) + .flat_map(Turn::item_payloads) .filter_map(|item| match item { - ThreadItem::UserMessage { content, .. } => Some(content), + ThreadItemPayload::UserMessage { content, .. } => Some(content), _ => None, }) .filter(|content| { diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 787c7e086aff..98f48e201b7f 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -2589,7 +2589,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> let initial_cells = crate::thread_transcript::thread_items_to_transcript_cells( Some(thread_id), &app.config.cwd, - started.turns.iter().flat_map(|turn| turn.items.clone()), + started.turns.iter().flat_map(Turn::item_payloads).cloned(), crate::thread_transcript::RawReasoningVisibility::Hidden, Some(&app.config), ); @@ -2763,7 +2763,7 @@ async fn transcript_alt_beginning_loads_every_older_history_page() -> Result<()> let initial_cells = crate::thread_transcript::thread_items_to_transcript_cells( Some(thread_id), &app.config.cwd, - started.turns.iter().flat_map(|turn| turn.items.clone()), + started.turns.iter().flat_map(Turn::item_payloads).cloned(), crate::thread_transcript::RawReasoningVisibility::Hidden, Some(&app.config), ); @@ -3179,7 +3179,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri let mut initial_cells = crate::thread_transcript::thread_items_to_transcript_cells( Some(thread_id), &app.config.cwd, - started.turns.iter().flat_map(|turn| turn.items.clone()), + started.turns.iter().flat_map(Turn::item_payloads).cloned(), crate::thread_transcript::RawReasoningVisibility::Hidden, Some(&app.config), ); @@ -3709,7 +3709,7 @@ async fn cold_paginated_subagent_transcript_excludes_inherited_parent_history() let [child_entry] = child_item_page.data.as_slice() else { panic!("paginated subagent should expose exactly one child message"); }; - let ThreadItem::UserMessage { content, .. } = &child_entry.item else { + let ThreadItemPayload::UserMessage { content, .. } = &child_entry.item.payload else { panic!("paginated subagent should expose its child user message"); }; assert_eq!(resumed.session.thread_id, child_thread_id); @@ -3725,9 +3725,9 @@ async fn cold_paginated_subagent_transcript_excludes_inherited_parent_history() resumed .turns .iter() - .flat_map(|turn| turn.items.iter()) + .flat_map(Turn::item_payloads) .collect::>(), - vec![&child_entry.item], + vec![&child_entry.item.payload], ); let cells = crate::thread_transcript::load_session_transcript( diff --git a/codex-rs/tui/src/app/tests/startup_frame_tests.rs b/codex-rs/tui/src/app/tests/startup_frame_tests.rs index 2c6adf43c188..9fe4a7663bdb 100644 --- a/codex-rs/tui/src/app/tests/startup_frame_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_frame_tests.rs @@ -94,7 +94,7 @@ async fn owned_startup_hides_tip_in_transcript() -> Result<()> { async fn owned_startup_preserves_loading_until_resume_replay_is_applied() -> Result<()> { for items in [ Vec::new(), - vec![ThreadItem::AgentMessage { + vec![ThreadItemPayload::AgentMessage { id: "answer".into(), text: "Retained **answer** after resume.".into(), phase: None, diff --git a/codex-rs/tui/src/app/tests/streaming_interleave_e2e.rs b/codex-rs/tui/src/app/tests/streaming_interleave_e2e.rs index a1db23cfa98d..63d9de8dd3ee 100644 --- a/codex-rs/tui/src/app/tests/streaming_interleave_e2e.rs +++ b/codex-rs/tui/src/app/tests/streaming_interleave_e2e.rs @@ -126,23 +126,23 @@ async fn command_start_before_message_completion_does_not_duplicate_preamble() - observed_order.push("agent delta"); } ServerNotification::ItemStarted(started) - if matches!(&started.item, ThreadItem::CommandExecution { .. }) => + if matches!(&started.item.payload, ThreadItemPayload::CommandExecution { .. }) => { observed_order.push("command started"); release_completion.take().expect("first command start").send(()).expect("completion gate should remain open"); } ServerNotification::ItemCompleted(completed) - if matches!(&completed.item, ThreadItem::AgentMessage { text, .. } if text == PREAMBLE) => + if matches!(&completed.item.payload, ThreadItemPayload::AgentMessage { text, .. } if text == PREAMBLE) => { observed_order.push("agent completed"); } ServerNotification::ItemCompleted(completed) => { - if let ThreadItem::CommandExecution { + if let ThreadItemPayload::CommandExecution { status, aggregated_output, exit_code, .. - } = &completed.item + } = &completed.item.payload { completed_command = Some(( status.clone(), diff --git a/codex-rs/tui/src/app/tests/thread_usage.rs b/codex-rs/tui/src/app/tests/thread_usage.rs index 8bd19001509d..8d6d408de69a 100644 --- a/codex-rs/tui/src/app/tests/thread_usage.rs +++ b/codex-rs/tui/src/app/tests/thread_usage.rs @@ -280,7 +280,7 @@ async fn terminal_reflow_rebases_pending_status_update_to_new_width() -> Result< app.insert_history_cell( &mut tui, Box::new( - history_cell::DynamicToolCallCell::from_item(ThreadItem::DynamicToolCall { + history_cell::DynamicToolCallCell::from_item(ThreadItemPayload::DynamicToolCall { id: "pending-tool".into(), namespace: None, tool: "lookup".into(), diff --git a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs index b762305b4ad9..8ff73ff2b027 100644 --- a/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs +++ b/codex-rs/tui/src/app/tests/unavailable_commands_tests.rs @@ -154,7 +154,7 @@ async fn unavailable_thread_new_and_clear_start_a_writable_session() -> Result<( vec![test_turn( "review", TurnStatus::InProgress, - vec![ThreadItem::EnteredReviewMode { + vec![ThreadItemPayload::EnteredReviewMode { id: "review-start".into(), review: "changes against main".into(), }], diff --git a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs index d3d07eded45a..ca06b5196d0b 100644 --- a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs +++ b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs @@ -239,19 +239,22 @@ async fn inactive_thread_user_verification_preserves_foreground_stream() -> Resu /*replay_kind*/ None, ); app.chat_widget.handle_server_notification( - ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { - thread_id: foreground_thread_id.to_string(), - turn_id: "turn-foreground".to_string(), - completed_at_ms: 0, - item: ThreadItem::AgentMessage { - id: "message-foreground".to_string(), - text: "The foreground answer continues.".to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }, - }), + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + foreground_thread_id.to_string(), + "turn-foreground".to_string(), + ThreadItemPayload::AgentMessage { + id: "message-foreground".to_string(), + text: "The foreground answer continues.".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index a6ca1a22b647..f810d38745a5 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -26,17 +26,23 @@ pub(super) enum ThreadBufferedEvent { FeedbackSubmission(FeedbackThreadEvent), } -fn is_voice_handoff_item(item: &ThreadItem) -> bool { - matches!(item, ThreadItem::UserMessage { content, .. } +fn is_voice_handoff_item(item: &ThreadItemPayload) -> bool { + matches!(item, ThreadItemPayload::UserMessage { content, .. } if crate::chatwidget::realtime_delegation_input(content).is_some()) } -fn hide_private_items_after_voice_handoff(items: &mut Vec, delegated: bool) { - let has_marker = items.iter().any(is_voice_handoff_item); +fn hide_private_items_after_voice_handoff( + items: &mut Vec, + delegated: bool, +) { + let has_marker = items + .iter() + .any(|entry| is_voice_handoff_item(&entry.payload)); // An evicted marker leaves no reliable order, so hide private items // conservatively. A present marker lets earlier typed output survive. let mut voice_started = delegated && !has_marker; - items.retain(|item| { + items.retain(|entry| { + let item = &entry.payload; if is_voice_handoff_item(item) { voice_started = true; } @@ -83,11 +89,11 @@ impl ThreadEventStore { pub(super) fn event_survives_session_refresh(event: &mut ThreadBufferedEvent) -> bool { if let ThreadBufferedEvent::Notification(notification) = event && let ServerNotification::ItemCompleted(notification) = notification.as_mut() - && let ThreadItem::AgentMessage { + && let ThreadItemPayload::AgentMessage { questions: Some(_), text, .. - } = &mut notification.item + } = &mut notification.item.payload { // Refreshed turns contain the text; retain only the live question state. text.clear(); @@ -182,11 +188,11 @@ impl ThreadEventStore { fn push_notification_inner(&mut self, notification: Cow<'_, ServerNotification>) { let user_item = match notification.as_ref() { - ServerNotification::ItemStarted(n) => Some((&n.turn_id, &n.item)), - ServerNotification::ItemCompleted(n) => Some((&n.turn_id, &n.item)), + ServerNotification::ItemStarted(n) => Some((&n.turn_id, &n.item.payload)), + ServerNotification::ItemCompleted(n) => Some((&n.turn_id, &n.item.payload)), _ => None, }; - if let Some((turn_id, ThreadItem::UserMessage { content, .. })) = user_item + if let Some((turn_id, ThreadItemPayload::UserMessage { content, .. })) = user_item && turn_id.len() <= 512 && crate::chatwidget::realtime_delegation_input(content).is_some() && !self.delegated_turns.contains(turn_id) @@ -242,28 +248,36 @@ impl ThreadEventStore { } ServerNotification::ItemStarted( started @ codex_app_server_protocol::ItemStartedNotification { - item: ThreadItem::Reasoning { id, .. }, + item: + codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::Reasoning { id, .. }, + .. + }, .. }, ) if !self.delegated_turns.contains(&started.turn_id) && !self.turns.iter().any(|turn| { - turn.id == started.turn_id && turn.items.iter().any(is_voice_handoff_item) + turn.id == started.turn_id + && turn + .items + .iter() + .any(|entry| is_voice_handoff_item(&entry.payload)) }) => { self.active_reasoning_item = - Some(codex_app_server_protocol::ItemStartedNotification { - thread_id: started.thread_id.clone(), - turn_id: started.turn_id.clone(), - item: ThreadItem::Reasoning { + Some(codex_app_server_protocol::ItemStartedNotification::new( + started.thread_id.clone(), + started.turn_id.clone(), + ThreadItemPayload::Reasoning { id: id.clone(), summary: Vec::new(), content: Vec::new(), }, - started_at_ms: started.started_at_ms, - }); + started.started_at_ms, + )); } ServerNotification::ItemCompleted(completed) - if matches!(&completed.item, ThreadItem::Reasoning { .. }) + if matches!(&completed.item.payload, ThreadItemPayload::Reasoning { .. }) && self.active_reasoning_item.as_ref().is_some_and(|started| { started.turn_id == completed.turn_id && started.item.id() == completed.item.id() @@ -384,7 +398,7 @@ impl ThreadEventStore { .iter() .filter(|turn| { turn.items.iter().any(|item| { - matches!(item, ThreadItem::UserMessage { content, .. } + matches!(&item.payload, ThreadItemPayload::UserMessage { content, .. } if crate::chatwidget::realtime_delegation_input(content).is_some()) }) }) @@ -399,10 +413,14 @@ impl ThreadEventStore { .iter() .filter_map(|event| match event { ThreadBufferedEvent::Notification(notification) => match notification.as_ref() { - ServerNotification::ItemStarted(n) if is_voice_handoff_item(&n.item) => { + ServerNotification::ItemStarted(n) + if is_voice_handoff_item(&n.item.payload) => + { Some(n.turn_id.clone()) } - ServerNotification::ItemCompleted(n) if is_voice_handoff_item(&n.item) => { + ServerNotification::ItemCompleted(n) + if is_voice_handoff_item(&n.item.payload) => + { Some(n.turn_id.clone()) } _ => None, @@ -424,15 +442,15 @@ impl ThreadEventStore { return true; }; match notification.as_ref() { - ServerNotification::ItemStarted(n) if is_voice_handoff_item(&n.item) => { + ServerNotification::ItemStarted(n) if is_voice_handoff_item(&n.item.payload) => { voice_started.insert(n.turn_id.clone()); } - ServerNotification::ItemCompleted(n) if is_voice_handoff_item(&n.item) => { + ServerNotification::ItemCompleted(n) if is_voice_handoff_item(&n.item.payload) => { voice_started.insert(n.turn_id.clone()); } ServerNotification::ItemStarted(n) => { - if let ThreadItem::AgentMessage { id, .. } | ThreadItem::Reasoning { id, .. } = - &n.item + if let ThreadItemPayload::AgentMessage { id, .. } | ThreadItemPayload::Reasoning { id, .. } = + &n.item.payload { let key = (n.turn_id.clone(), id.clone()); if voice_started.contains(&n.turn_id) { @@ -462,9 +480,9 @@ impl ThreadEventStore { || typed_items.contains(&(n.turn_id.clone(), n.item_id.clone())) } ServerNotification::ItemCompleted(n) if voice_started.contains(&n.turn_id) => { - matches!(&n.item, ThreadItem::AgentMessage { id, .. } | ThreadItem::Reasoning { id, .. } + matches!(&n.item.payload, ThreadItemPayload::AgentMessage { id, .. } | ThreadItemPayload::Reasoning { id, .. } if typed_items.contains(&(n.turn_id.clone(), id.clone()))) - || !crate::chatwidget::is_private_realtime_agent_item(&n.item) + || !crate::chatwidget::is_private_realtime_agent_item(&n.item.payload) } ServerNotification::TurnCompleted(n) if delegated.contains(&n.turn.id) => { hide_private_items_after_voice_handoff( @@ -547,10 +565,10 @@ pub(super) fn file_change_changes<'a>( }; let (candidate_turn_id, item) = match notification.as_ref() { ServerNotification::ItemStarted(notification) => { - (¬ification.turn_id, ¬ification.item) + (¬ification.turn_id, ¬ification.item.payload) } ServerNotification::ItemCompleted(notification) => { - (¬ification.turn_id, ¬ification.item) + (¬ification.turn_id, ¬ification.item.payload) } _ => return None, }; @@ -562,17 +580,17 @@ pub(super) fn file_change_changes<'a>( .iter() .rev() .filter(|turn| turn_id_matches(turn_id, &turn.id)) - .flat_map(|turn| turn.items.iter().rev()), + .flat_map(|turn| turn.item_payloads().rev()), ) .find_map(|item| file_change_item_changes(item, item_id)) } fn file_change_item_changes( - item: &ThreadItem, + item: &ThreadItemPayload, item_id: &str, ) -> Option> { match item { - ThreadItem::FileChange { id, changes, .. } if id == item_id => Some(changes.clone()), + ThreadItemPayload::FileChange { id, changes, .. } if id == item_id => Some(changes.clone()), _ => None, } } @@ -650,6 +668,7 @@ mod tests { use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ThreadAttachmentOperation; use codex_app_server_protocol::ThreadAttachmentUpdatedNotification; + use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadRealtimeAudioChunk; use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification; use codex_app_server_protocol::TurnCompletedNotification; @@ -686,12 +705,12 @@ mod tests { } } - fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { + fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), status, error: None, started_at: None, @@ -972,9 +991,10 @@ mod tests { fn refresh_retains_live_questions_without_replaying_their_text() { let mut expected = serde_json::json!({ "method": "item/completed", "params": { - "threadId": "thread", "turnId": "turn", "completedAtMs": 0, + "threadId": "thread", "turnId": "turn", "completedAtMs": 20, "item": { "type": "agentMessage", "id": "question", "text": "already in the snapshot", + "startedAtMs": 10, "completedAtMs": 20, "phase": null, "memoryCitation": null, "delivery": null, "questions": [{"title": "Which way?", "options": null}] } @@ -1054,47 +1074,53 @@ mod tests { let thread_id = ThreadId::new().to_string(); let turn_id = "mixed-reasoning-turn".to_string(); let mut store = ThreadEventStore::new(/*capacity*/ 16); - let typed = ThreadItem::Reasoning { + let typed = ThreadItemPayload::Reasoning { id: "typed-reasoning".into(), summary: Vec::new(), content: Vec::new(), }; - store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - item: typed, - started_at_ms: 0, - })); - store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - item: ThreadItem::UserMessage { - id: "voice-marker".into(), - client_id: None, - content: vec![UserInput::Text { + store.push_notification(ServerNotification::ItemStarted( + ItemStartedNotification::new( + thread_id.clone(), + turn_id.clone(), + typed, + /*started_at_ms*/ 0, + ), + )); + store.push_notification(ServerNotification::ItemStarted( + ItemStartedNotification::new( + thread_id.clone(), + turn_id.clone(), + ThreadItemPayload::UserMessage { + id: "voice-marker".into(), + client_id: None, + content: vec![UserInput::Text { text: "spoken follow-up" .into(), text_elements: Vec::new(), }], - }, - started_at_ms: 0, - })); + }, + /*started_at_ms*/ 0, + ), + )); for (id, text) in [ ("typed-reasoning", "Typed tail"), ("private-reasoning", "Private voice reasoning"), ] { if id == "private-reasoning" { - store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - item: ThreadItem::Reasoning { - id: id.into(), - summary: Vec::new(), - content: Vec::new(), - }, - started_at_ms: 0, - })); + store.push_notification(ServerNotification::ItemStarted( + ItemStartedNotification::new( + thread_id.clone(), + turn_id.clone(), + ThreadItemPayload::Reasoning { + id: id.into(), + summary: Vec::new(), + content: Vec::new(), + }, + /*started_at_ms*/ 0, + ), + )); } store.push_notification(ServerNotification::ReasoningSummaryTextDelta( ReasoningSummaryTextDeltaNotification { @@ -1106,16 +1132,17 @@ mod tests { }, )); store.push_notification(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: thread_id.clone(), - turn_id: turn_id.clone(), - item: ThreadItem::Reasoning { + ItemCompletedNotification::new( + thread_id.clone(), + turn_id.clone(), + ThreadItemPayload::Reasoning { id: id.into(), summary: vec![text.into()], content: Vec::new(), }, - completed_at_ms: 0, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), )); } @@ -1126,8 +1153,8 @@ mod tests { .filter_map(|event| match event { ThreadBufferedEvent::Notification(notification) => match *notification { ServerNotification::ReasoningSummaryTextDelta(n) => Some(n.delta), - ServerNotification::ItemCompleted(n) => match n.item { - ThreadItem::Reasoning { summary, .. } => Some(summary.join("")), + ServerNotification::ItemCompleted(n) => match n.item.payload { + ThreadItemPayload::Reasoning { summary, .. } => Some(summary.join("")), _ => None, }, _ => None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 07a986931b2e..55042b535e46 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1731,23 +1731,23 @@ impl App { let confirmed_message_ids = snapshot .turns .iter() - .flat_map(|turn| &turn.items) + .flat_map(Turn::item_payloads) .chain(snapshot.events.iter().filter_map(|event| { let ThreadBufferedEvent::Notification(notification) = event else { return None; }; if let ServerNotification::ItemStarted(notification) = notification.as_ref() { - Some(¬ification.item) + Some(¬ification.item.payload) } else if let ServerNotification::ItemCompleted(notification) = notification.as_ref() { - Some(¬ification.item) + Some(¬ification.item.payload) } else { None } })) .filter_map(|item| { - if let ThreadItem::UserMessage { client_id, .. } = item { + if let ThreadItemPayload::UserMessage { client_id, .. } = item { client_id.clone() } else { None @@ -2046,7 +2046,7 @@ impl App { let automatic_title_user_message = if self.chat_widget.thread_name().is_none() && let ThreadBufferedEvent::Notification(notification) = &event && let ServerNotification::ItemCompleted(notification) = notification.as_ref() - && let ThreadItem::UserMessage { content, .. } = ¬ification.item + && let ThreadItemPayload::UserMessage { content, .. } = ¬ification.item.payload { Some( content diff --git a/codex-rs/tui/src/app/thread_title.rs b/codex-rs/tui/src/app/thread_title.rs index e248dde09b40..3b09a741d0e5 100644 --- a/codex-rs/tui/src/app/thread_title.rs +++ b/codex-rs/tui/src/app/thread_title.rs @@ -14,8 +14,9 @@ use crate::temporary_structured_request::run_temporary_structured_turn; use crate::temporary_structured_request::start_temporary_thread; use crate::temporary_structured_request::unsubscribe_temporary_thread; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSource; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::UserInput; use codex_protocol::ThreadId; use codex_protocol::models::MessagePhase; @@ -215,7 +216,7 @@ impl App { store .turns .iter() - .flat_map(|turn| turn.items.iter()) + .flat_map(Turn::item_payloads) .chain(store.buffer.iter().filter_map(|event| { let ThreadBufferedEvent::Notification(notification) = event else { return None; @@ -226,7 +227,7 @@ impl App { return None; }; - Some(¬ification.item) + Some(¬ification.item.payload) })) .filter(|item| seen.insert(item.id().to_string())), ) @@ -293,14 +294,14 @@ pub(super) fn thread_title_prompt(user_message: &str) -> String { /// Format recent substantive messages chronologically without trusting their markup. pub(super) fn recent_conversation_messages<'a, I>(items: I) -> Option where - I: IntoIterator, + I: IntoIterator, I::IntoIter: DoubleEndedIterator, { let mut messages = items .into_iter() .rev() .filter_map(|item| match item { - ThreadItem::UserMessage { content, .. } => { + ThreadItemPayload::UserMessage { content, .. } => { let text = content .iter() .filter_map(|input| match input { @@ -314,7 +315,7 @@ where (!text.trim().is_empty()).then_some(("user", text)) } - ThreadItem::AgentMessage { text, phase, .. } + ThreadItemPayload::AgentMessage { text, phase, .. } if !matches!(phase, Some(MessagePhase::Commentary)) && !text.trim().is_empty() => { Some(("assistant", text.clone())) diff --git a/codex-rs/tui/src/app/thread_title_tests.rs b/codex-rs/tui/src/app/thread_title_tests.rs index 68a0644d2b1e..2f7eb1d06fa9 100644 --- a/codex-rs/tui/src/app/thread_title_tests.rs +++ b/codex-rs/tui/src/app/thread_title_tests.rs @@ -20,6 +20,7 @@ use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -270,7 +271,10 @@ async fn check_thread_title_generation(scenario: TitleScenario) -> color_eyre::R .push(Turn { id: "existing-turn".to_string(), root_turn_id: None, - items: vec![title_user_message("user-message", "Fix the login timeout")], + items: vec![ThreadItem::untimed(title_user_message( + "user-message", + "Fix the login timeout", + ))], items_view: Default::default(), status: TurnStatus::Completed, error: None, @@ -286,12 +290,13 @@ async fn check_thread_title_generation(scenario: TitleScenario) -> color_eyre::R &mut tui, &mut app_server, ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted( - ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: "existing-turn".to_string(), - item: title_user_message("user-message", "Fix the login timeout"), - completed_at_ms: 0, - }, + ItemCompletedNotification::new( + thread_id.to_string(), + "existing-turn".to_string(), + title_user_message("user-message", "Fix the login timeout"), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), ))), ) .await?; @@ -742,8 +747,8 @@ fn truncates_generated_titles_without_splitting_unicode() { assert_eq!(parse_thread_title(&response), Some(expected)); } -fn title_user_message(id: &str, text: &str) -> ThreadItem { - ThreadItem::UserMessage { +fn title_user_message(id: &str, text: &str) -> ThreadItemPayload { + ThreadItemPayload::UserMessage { id: id.to_string(), client_id: None, content: vec![UserInput::Text { @@ -753,8 +758,8 @@ fn title_user_message(id: &str, text: &str) -> ThreadItem { } } -fn title_agent_message(id: &str, text: &str, phase: Option) -> ThreadItem { - ThreadItem::AgentMessage { +fn title_agent_message(id: &str, text: &str, phase: Option) -> ThreadItemPayload { + ThreadItemPayload::AgentMessage { id: id.to_string(), text: text.to_string(), phase, diff --git a/codex-rs/tui/src/app/transcript_export.rs b/codex-rs/tui/src/app/transcript_export.rs index 273ecee194dd..00775bba50a6 100644 --- a/codex-rs/tui/src/app/transcript_export.rs +++ b/codex-rs/tui/src/app/transcript_export.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::Arc; use codex_app_server_client::TypedRequestError; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_protocol::ThreadId; use codex_protocol::models::local_image_label_text; @@ -136,9 +136,9 @@ pub(super) async fn load_export_transcript( Ok(cells) } -fn export_activity_cell(item: &ThreadItem) -> Option { +fn export_activity_cell(item: &ThreadItemPayload) -> Option { let lines = match item { - ThreadItem::FileChange { + ThreadItemPayload::FileChange { changes, status, .. } => { let mut lines = @@ -149,7 +149,7 @@ fn export_activity_cell(item: &ThreadItem) -> Option { } lines } - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { server, tool, status, @@ -199,7 +199,7 @@ fn export_activity_cell(item: &ThreadItem) -> Option { Some(PlainHistoryCell::new(lines)) } -fn visible_export_items(turns: Vec) -> Vec { +fn visible_export_items(turns: Vec) -> Vec { let mut visible = Vec::new(); let mut review_mode = false; let mut previous_turn = None; @@ -208,13 +208,15 @@ fn visible_export_items(turns: Vec) -> Vec { let hidden_nested_review_turn = previous_turn.as_ref().is_some_and(|previous| { crate::app_backtrack::is_hidden_nested_review_turn(previous, &turn) }); - for item in turn.items.iter().cloned() { + for item in turn.item_payloads().cloned() { match item { - ThreadItem::EnteredReviewMode { .. } | ThreadItem::ExitedReviewMode { .. } => { - review_mode = matches!(item, ThreadItem::EnteredReviewMode { .. }); + ThreadItemPayload::EnteredReviewMode { .. } + | ThreadItemPayload::ExitedReviewMode { .. } => { + review_mode = matches!(item, ThreadItemPayload::EnteredReviewMode { .. }); visible.push(item); } - ThreadItem::UserMessage { .. } if review_mode || hidden_nested_review_turn => {} + ThreadItemPayload::UserMessage { .. } + if review_mode || hidden_nested_review_turn => {} _ => visible.push(item), } } diff --git a/codex-rs/tui/src/app/transcript_export_tests.rs b/codex-rs/tui/src/app/transcript_export_tests.rs index 21ee6e4c76e6..15d1555b1591 100644 --- a/codex-rs/tui/src/app/transcript_export_tests.rs +++ b/codex-rs/tui/src/app/transcript_export_tests.rs @@ -1,3 +1,4 @@ +use codex_app_server_protocol::ThreadItem; use std::sync::Arc; use pretty_assertions::assert_eq; @@ -10,7 +11,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::new_proposed_plan; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; @@ -63,7 +64,7 @@ fn markdown_transcript_preserves_messages_and_formats_activity() { #[test] fn transcript_export_excludes_hidden_review_prompts_and_nested_duplicates() { - let user = |id: &str, text: &str| ThreadItem::UserMessage { + let user = |id: &str, text: &str| ThreadItemPayload::UserMessage { id: id.to_string(), client_id: None, content: vec![UserInput::Text { @@ -71,20 +72,20 @@ fn transcript_export_excludes_hidden_review_prompts_and_nested_duplicates() { text_elements: Vec::new(), }], }; - let entered_review = ThreadItem::EnteredReviewMode { + let entered_review = ThreadItemPayload::EnteredReviewMode { id: "review-enter".to_string(), review: "review".to_string(), }; - let exited_review = ThreadItem::ExitedReviewMode { + let exited_review = ThreadItemPayload::ExitedReviewMode { id: "review-exit".to_string(), review: "review".to_string(), }; let duplicate_one = user("duplicate-one", "duplicate review prompt"); let duplicate_two = user("duplicate-two", "duplicate review prompt"); - let turn = |id: &str, items: Vec, status| Turn { + let turn = |id: &str, items: Vec, status| Turn { id: id.to_string(), root_turn_id: None, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), items_view: TurnItemsView::Full, status, error: None, @@ -149,7 +150,7 @@ fn persisted_transcript_includes_file_and_mcp_details() { }), ] .into_iter() - .map(|item| serde_json::from_value::(item).expect("valid thread item")) + .map(|item| serde_json::from_value::(item).expect("valid thread item")) .filter_map(|item| { super::export_activity_cell(&item).map(|cell| Arc::new(cell) as Arc) }) @@ -220,7 +221,7 @@ fn persisted_web_and_image_activity_preserves_full_details() { serde_json::json!({ "type": "imageView", "id": "image", "path": "C:\\workspace\\assets\\screenshot.png" }), - ].into_iter().map(|item| serde_json::from_value::(item).expect("valid thread item")); + ].into_iter().map(|item| serde_json::from_value::(item).expect("valid thread item")); let cells = crate::thread_transcript::thread_items_to_transcript_cells( /*thread_id*/ None, &codex_utils_absolute_path::AbsolutePathBuf::current_dir().expect("cwd"), diff --git a/codex-rs/tui/src/app/turn_tips.rs b/codex-rs/tui/src/app/turn_tips.rs index f3f481b755bb..caf751a65e3d 100644 --- a/codex-rs/tui/src/app/turn_tips.rs +++ b/codex-rs/tui/src/app/turn_tips.rs @@ -44,8 +44,8 @@ pub(super) enum TipSurface { Completion, } -fn is_final_answer(item: &ThreadItem) -> bool { - matches!(item, ThreadItem::AgentMessage { text, phase: Some(MessagePhase::FinalAnswer) | None, .. } +fn is_final_answer(item: &ThreadItemPayload) -> bool { + matches!(item, ThreadItemPayload::AgentMessage { text, phase: Some(MessagePhase::FinalAnswer) | None, .. } if !text.trim().is_empty()) } @@ -84,14 +84,18 @@ impl TurnTips { if item.thread_id == current.thread_id.to_string() && item.turn_id == current.turn_id => { - current.has_final_answer |= is_final_answer(&item.item); + current.has_final_answer |= is_final_answer(&item.item.payload); } ServerNotification::TurnCompleted(completed) if completed.thread_id == current.thread_id.to_string() && completed.turn.id == current.turn_id && matches!(current.phase, Phase::Working) => { - current.has_final_answer |= completed.turn.items.iter().any(is_final_answer); + current.has_final_answer |= completed + .turn + .items + .iter() + .any(|entry| is_final_answer(&entry.payload)); if completed.turn.status == TurnStatus::Completed && current.has_final_answer && !current.shown diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index fb8babd3d195..f3e5d5a844e8 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -44,7 +44,7 @@ use crate::pager_overlay::Overlay; use crate::pager_overlay::TranscriptHistoryState; use crate::tui; use crate::tui::TuiEvent; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; @@ -539,12 +539,12 @@ pub(crate) fn backtrack_revert_before_turn_id( &previous.items }; is_hidden_nested_review_turn(previous, turn) - && previous_items - .iter() - .any(|item| matches!(item, ThreadItem::EnteredReviewMode { .. })) - && previous_items - .iter() - .any(|item| matches!(item, ThreadItem::ExitedReviewMode { .. })) + && previous_items.iter().any(|item| { + matches!(&item.payload, ThreadItemPayload::EnteredReviewMode { .. }) + }) + && previous_items.iter().any(|item| { + matches!(&item.payload, ThreadItemPayload::ExitedReviewMode { .. }) + }) }); let first_item = if turn_index == start_turn { start_item @@ -553,21 +553,22 @@ pub(crate) fn backtrack_revert_before_turn_id( }; // Clipped items affect whether an input is a steer, but were never displayed and // therefore must not change the transcript's review visibility or prompt ordinals. - let mut user_messages_in_turn = turn.items[..first_item] - .iter() - .filter(|item| matches!(item, ThreadItem::UserMessage { .. })) + let mut user_messages_in_turn = turn + .item_payloads() + .take(first_item) + .filter(|item| matches!(item, ThreadItemPayload::UserMessage { .. })) .count(); - for item in &turn.items[first_item..] { + for item in turn.item_payloads().skip(first_item) { let content = match item { - ThreadItem::EnteredReviewMode { .. } => { + ThreadItemPayload::EnteredReviewMode { .. } => { review_mode = true; continue; } - ThreadItem::ExitedReviewMode { .. } => { + ThreadItemPayload::ExitedReviewMode { .. } => { review_mode = false; continue; } - ThreadItem::UserMessage { content, .. } => content, + ThreadItemPayload::UserMessage { content, .. } => content, _ => continue, }; let is_steer = user_messages_in_turn > 0; @@ -625,19 +626,17 @@ pub(crate) fn is_hidden_nested_review_turn(previous: &Turn, turn: &Turn) -> bool || turn.status != TurnStatus::Interrupted || turn.completed_at.is_some() || !previous - .items - .iter() - .any(|item| matches!(item, ThreadItem::EnteredReviewMode { .. })) + .item_payloads() + .any(|item| matches!(item, ThreadItemPayload::EnteredReviewMode { .. })) || !previous - .items - .iter() - .any(|item| matches!(item, ThreadItem::ExitedReviewMode { .. })) + .item_payloads() + .any(|item| matches!(item, ThreadItemPayload::ExitedReviewMode { .. })) { return false; } - let mut user_messages = turn.items.iter().filter_map(|item| match item { - ThreadItem::UserMessage { content, .. } => Some(content), + let mut user_messages = turn.item_payloads().filter_map(|item| match item { + ThreadItemPayload::UserMessage { content, .. } => Some(content), _ => None, }); matches!( @@ -722,6 +721,7 @@ mod tests { use crate::bottom_pane::MentionBinding; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; + use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::UserInput; use pretty_assertions::assert_eq; use ratatui::prelude::Line; @@ -745,7 +745,7 @@ mod tests { id: turn_id.to_string(), root_turn_id: None, items: (0..user_messages) - .map(|index| ThreadItem::UserMessage { + .map(|index| ThreadItemPayload::UserMessage { id: format!("user-{index}"), client_id: None, content: vec![UserInput::Text { @@ -753,6 +753,7 @@ mod tests { text_elements: Vec::new(), }], }) + .map(ThreadItem::untimed) .collect(), items_view: codex_app_server_protocol::TurnItemsView::Full, status, @@ -776,7 +777,7 @@ mod tests { #[test] fn backtrack_revert_before_turn_id_resolves_first_and_later_prompts() { let mut hidden = turn("hidden", TurnStatus::Completed, /*user_messages*/ 1); - let ThreadItem::UserMessage { content, .. } = &mut hidden.items[0] else { + let ThreadItemPayload::UserMessage { content, .. } = &mut hidden.items[0].payload else { unreachable!() }; *content = vec![UserInput::Text { @@ -917,15 +918,17 @@ mod tests { ); review_turn.items.insert( /*index*/ 0, - ThreadItem::EnteredReviewMode { + ThreadItem::untimed(ThreadItemPayload::EnteredReviewMode { id: "review-start".to_string(), review: "changes against main".to_string(), - }, + }), ); - review_turn.items.push(ThreadItem::ExitedReviewMode { - id: "review-end".to_string(), - review: "review complete".to_string(), - }); + review_turn + .items + .push(ThreadItem::untimed(ThreadItemPayload::ExitedReviewMode { + id: "review-end".to_string(), + review: "review complete".to_string(), + })); let turns = vec![ turn("turn-1", TurnStatus::Completed, /*user_messages*/ 1), review_turn, @@ -963,14 +966,14 @@ mod tests { "Review the current code changes (staged, unstaged, and untracked files)."; let review_turn = Turn { items: vec![ - ThreadItem::EnteredReviewMode { + ThreadItem::untimed(ThreadItemPayload::EnteredReviewMode { id: "review-start".to_string(), review: review_hint.to_string(), - }, - ThreadItem::ExitedReviewMode { + }), + ThreadItem::untimed(ThreadItemPayload::ExitedReviewMode { id: "review-end".to_string(), review: "review complete".to_string(), - }, + }), ], ..turn( "turn-review", @@ -980,7 +983,7 @@ mod tests { }; let review_child_turn = Turn { items: (0..2) - .map(|index| ThreadItem::UserMessage { + .map(|index| ThreadItemPayload::UserMessage { id: format!("review-prompt-{index}"), client_id: None, content: vec![UserInput::Text { @@ -988,6 +991,7 @@ mod tests { text_elements: Vec::new(), }], }) + .map(ThreadItem::untimed) .collect(), ..turn( "turn-review-child", @@ -1029,7 +1033,7 @@ mod tests { #[test] fn backtrack_revert_before_turn_id_restores_canonical_mention_bindings() { let mut selected_turn = turn("turn-2", TurnStatus::Completed, /*user_messages*/ 1); - selected_turn.items = vec![ThreadItem::UserMessage { + selected_turn.items = vec![ThreadItem::untimed(ThreadItemPayload::UserMessage { id: "selected-prompt".to_string(), client_id: None, content: vec![ @@ -1050,7 +1054,7 @@ mod tests { path: "app://google_calendar".to_string(), }, ], - }]; + })]; let turns = vec![ turn("turn-1", TurnStatus::Completed, /*user_messages*/ 1), selected_turn, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2bc1ef9a1aa8..9d0166a7e7ba 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2684,6 +2684,7 @@ mod tests { use crate::legacy_core::config::ConfigOverrides; use app_test_support::create_fake_paginated_rollout; use app_test_support::create_fake_rollout; + use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -4127,7 +4128,7 @@ mod tests { assert_eq!(regular.turns.len(), 1); assert!(matches!( regular.turns[0].items.as_slice(), - [codex_app_server_protocol::ThreadItem::UserMessage { content, .. }] + [codex_app_server_protocol::ThreadItem { payload: codex_app_server_protocol::ThreadItemPayload::UserMessage { content, .. }, .. }] if content == &[UserInput::Text { text: "Saved user message".to_string(), text_elements: Vec::new(), @@ -4261,22 +4262,26 @@ mod tests { root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ - codex_app_server_protocol::ThreadItem::UserMessage { - id: "user-1".to_string(), - client_id: None, - content: vec![codex_app_server_protocol::UserInput::Text { - text: "hello from history".to_string(), - text_elements: Vec::new(), - }], - }, - codex_app_server_protocol::ThreadItem::AgentMessage { - id: "assistant-1".to_string(), - text: "assistant reply".to_string(), - phase: None, - memory_citation: None, - delivery: None, - questions: None, - }, + ThreadItem::untimed( + codex_app_server_protocol::ThreadItemPayload::UserMessage { + id: "user-1".to_string(), + client_id: None, + content: vec![codex_app_server_protocol::UserInput::Text { + text: "hello from history".to_string(), + text_elements: Vec::new(), + }], + }, + ), + ThreadItem::untimed( + codex_app_server_protocol::ThreadItemPayload::AgentMessage { + id: "assistant-1".to_string(), + text: "assistant reply".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + ), ], status: TurnStatus::Completed, error: None, diff --git a/codex-rs/tui/src/app_server_session/history.rs b/codex-rs/tui/src/app_server_session/history.rs index 55ce6daf3515..956bb96372ba 100644 --- a/codex-rs/tui/src/app_server_session/history.rs +++ b/codex-rs/tui/src/app_server_session/history.rs @@ -1,5 +1,6 @@ //! Bounded app-server transcript loading for resume, fork, and transcript views. +use codex_app_server_protocol::ThreadItem; use std::collections::HashSet; use super::AppServerSession; @@ -13,7 +14,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListCursor; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; @@ -208,7 +209,7 @@ impl AppServerSession { cursor: &str, page: ThreadItemsListResponse, turns: &mut Vec, - ) -> Result> { + ) -> Result> { if !self.is_older_history_page_pending(thread_id, cursor) { return Ok(Vec::new()); } @@ -268,7 +269,7 @@ impl AppServerSession { page: ThreadItemsListResponse, state: &mut ThreadHistoryPagination, turns: &mut Vec, - ) -> Result> { + ) -> Result> { state.next_item_cursor = advancing_cursor( state.next_item_cursor.as_deref(), page.next_cursor, @@ -305,8 +306,8 @@ impl AppServerSession { if let Some(turn) = turns.iter_mut().find(|turn| turn.id == entry.turn_id) && !turn.items.iter().any(|item| item.id() == entry.item.id()) { - items.push(entry.item.clone()); - turn.items.insert(/*index*/ 0, entry.item); + items.push(entry.item.payload.clone()); + turn.items.insert(/*index*/ 0, ThreadItem::from(entry)); turn.items_view = TurnItemsView::Summary; } } @@ -408,7 +409,7 @@ impl AppServerSession { fn rendered_history_rows( thread_id: ThreadId, thread: &Thread, - items: Vec, + items: Vec, config: &Config, local_settings: &crate::local_settings::LocalSettings, width: u16, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4b9d285d29d7..5cf8b6ac3deb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -106,7 +106,7 @@ use codex_app_server_protocol::SkillMetadata; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadTokenUsage; diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index a0533b315e51..c65cae1fec10 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -18,8 +18,8 @@ impl ChatWidget { self.restore_reasoning_status_header(); } - pub(super) fn on_command_execution_started(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { + pub(super) fn on_command_execution_started(&mut self, item: ThreadItemPayload) { + let ThreadItemPayload::CommandExecution { id, command, process_id, @@ -132,8 +132,8 @@ impl ChatWidget { } } - pub(super) fn on_command_execution_completed(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { + pub(super) fn on_command_execution_completed(&mut self, item: ThreadItemPayload) { + let ThreadItemPayload::CommandExecution { id, process_id, source, @@ -240,8 +240,8 @@ impl ChatWidget { } } - pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { - let ThreadItem::CommandExecution { + pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItemPayload) { + let ThreadItemPayload::CommandExecution { id, command, source, @@ -319,7 +319,7 @@ impl ChatWidget { /// standalone history entry instead of replacing or flushing the unrelated active exploring /// cell. If this method treated every unknown end as "complete the active cell", the UI could /// merge unrelated commands and hide still-running exploring work. - pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) { + pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItemPayload) { enum ExecEndTarget { // Normal case: the active exec cell already tracks this call id. ActiveTracked, @@ -330,7 +330,7 @@ impl ChatWidget { NewCell, } - let ThreadItem::CommandExecution { + let ThreadItemPayload::CommandExecution { id, command, process_id: _, diff --git a/codex-rs/tui/src/chatwidget/dynamic_activity.rs b/codex-rs/tui/src/chatwidget/dynamic_activity.rs index 348031a32694..ec2551258ab0 100644 --- a/codex-rs/tui/src/chatwidget/dynamic_activity.rs +++ b/codex-rs/tui/src/chatwidget/dynamic_activity.rs @@ -3,10 +3,10 @@ use super::*; impl ChatWidget { - pub(super) fn on_dynamic_tool_item(&mut self, item: ThreadItem) { + pub(super) fn on_dynamic_tool_item(&mut self, item: ThreadItemPayload) { let started = matches!( &item, - ThreadItem::DynamicToolCall { + ThreadItemPayload::DynamicToolCall { status: codex_app_server_protocol::DynamicToolCallStatus::InProgress, .. } @@ -24,8 +24,8 @@ impl ChatWidget { ); } - pub(crate) fn handle_dynamic_tool_item_now(&mut self, item: ThreadItem) { - let ThreadItem::DynamicToolCall { id, .. } = &item else { + pub(crate) fn handle_dynamic_tool_item_now(&mut self, item: ThreadItemPayload) { + let ThreadItemPayload::DynamicToolCall { id, .. } = &item else { return; }; let id = id.clone(); diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 2adf4548705b..7b1d1b6802e8 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -9,7 +9,7 @@ use crate::approval_events::ApplyPatchApprovalRequestEvent; use crate::approval_events::ExecApprovalRequestEvent; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::RequestId as AppServerRequestId; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_protocol::request_permissions::RequestPermissionsEvent; @@ -25,8 +25,8 @@ pub(crate) enum QueuedInterrupt { }, RequestPermissions(RequestPermissionsEvent), RequestUserInput(ToolRequestUserInputParams), - ItemStarted(ThreadItem), - ItemCompleted(ThreadItem), + ItemStarted(ThreadItemPayload), + ItemCompleted(ThreadItemPayload), } #[derive(Default)] @@ -87,11 +87,11 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::RequestUserInput(ev)); } - pub(crate) fn push_item_started(&mut self, item: ThreadItem) { + pub(crate) fn push_item_started(&mut self, item: ThreadItemPayload) { self.queue.push_back(QueuedInterrupt::ItemStarted(item)); } - pub(crate) fn push_item_completed(&mut self, item: ThreadItem) { + pub(crate) fn push_item_completed(&mut self, item: ThreadItemPayload) { self.queue.push_back(QueuedInterrupt::ItemCompleted(item)); } @@ -174,7 +174,7 @@ mod tests { use crate::approval_events::ExecApprovalRequestEvent; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; - use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadItemPayload; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -209,8 +209,8 @@ mod tests { } } - fn command_execution(call_id: &str) -> ThreadItem { - ThreadItem::CommandExecution { + fn command_execution(call_id: &str) -> ThreadItemPayload { + ThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: call_id.to_string(), diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 5bd2254d86f1..0cb7a7a984c3 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -437,11 +437,10 @@ impl ChatWidget { let last_agent_message = notification .turn - .items - .iter() + .item_payloads() .rev() .find_map(|item| match item { - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id, text, phase: Some(MessagePhase::FinalAnswer) | None, @@ -557,19 +556,19 @@ impl ChatWidget { replay_kind: Option, ) { self.restore_realtime_transcripts_before_turn(¬ification.turn_id); - match notification.item { - ThreadItem::UserMessage { content, .. } if replay_kind.is_none() => { + match notification.item.payload { + ThreadItemPayload::UserMessage { content, .. } if replay_kind.is_none() => { self.note_realtime_user_item_started(¬ification.turn_id, &content); } - ThreadItem::UserMessage { content, .. } + ThreadItemPayload::UserMessage { content, .. } if realtime::realtime_delegation_input(&content).is_some() => { self.remember_realtime_delegated_reasoning_turn(¬ification.turn_id); } - ThreadItem::AgentMessage { id, .. } if replay_kind.is_none() => { + ThreadItemPayload::AgentMessage { id, .. } if replay_kind.is_none() => { self.is_realtime_delegated_agent_item(¬ification.turn_id, &id); } - ThreadItem::Reasoning { id, .. } => { + ThreadItemPayload::Reasoning { id, .. } => { if replay_kind.is_none() && !self.is_realtime_delegated_reasoning_turn(¬ification.turn_id) { @@ -586,7 +585,7 @@ impl ChatWidget { self.on_reasoning_item_started(id); } } - ThreadItem::ContextCompaction { id } + ThreadItemPayload::ContextCompaction { id } if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) => { // Buffered starts reconstruct an in-flight compaction when switching tasks. @@ -601,19 +600,21 @@ impl ChatWidget { }; self.on_context_compaction_started(id, elapsed); } - item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item), - ThreadItem::FileChange { id: _, changes, .. } => { + item @ ThreadItemPayload::CommandExecution { .. } => { + self.on_command_execution_started(item) + } + ThreadItemPayload::FileChange { id: _, changes, .. } => { self.on_patch_apply_begin(file_update_changes_to_display(changes)); } - item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item), - item @ ThreadItem::DynamicToolCall { .. } => self.on_dynamic_tool_item(item), - ThreadItem::WebSearch(item) => { + item @ ThreadItemPayload::McpToolCall { .. } => self.on_mcp_tool_call_started(item), + item @ ThreadItemPayload::DynamicToolCall { .. } => self.on_dynamic_tool_item(item), + ThreadItemPayload::WebSearch(item) => { self.on_web_search_begin(item.id); } - ThreadItem::ImageGeneration(_) => { + ThreadItemPayload::ImageGeneration(_) => { self.on_image_generation_begin(); } - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -623,7 +624,7 @@ impl ChatWidget { model, reasoning_effort, agents_states, - } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { + } => self.on_collab_agent_tool_call(ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -634,7 +635,7 @@ impl ChatWidget { reasoning_effort, agents_states, }), - ThreadItem::EnteredReviewMode { review, .. } if replay_kind.is_none() => { + ThreadItemPayload::EnteredReviewMode { review, .. } if replay_kind.is_none() => { self.enter_review_mode_with_hint(review, /*from_replay*/ false); } _ => {} @@ -649,8 +650,8 @@ impl ChatWidget { self.restore_realtime_transcripts_before_turn(¬ification.turn_id); if replay_kind.is_none() && self.is_realtime_delegated_reasoning_turn(¬ification.turn_id) - && realtime::is_private_realtime_agent_item(¬ification.item) - && !matches!(¬ification.item, ThreadItem::AgentMessage { id, .. } | ThreadItem::Reasoning { id, .. } + && realtime::is_private_realtime_agent_item(¬ification.item.payload) + && !matches!(¬ification.item.payload, ThreadItemPayload::AgentMessage { id, .. } | ThreadItemPayload::Reasoning { id, .. } if matches!( self.realtime_conversation.agent_items.get(&(notification.turn_id.clone(), id.clone())), Some(realtime::RealtimeAgentItemOrigin::Typed) @@ -660,22 +661,24 @@ impl ChatWidget { } // Buffered live notifications can introduce questions; historical turn replay cannot. if replay_kind == Some(ReplayKind::ThreadSnapshot) - && let ThreadItem::AgentMessage { + && let ThreadItemPayload::AgentMessage { id, questions: Some(questions), .. - } = ¬ification.item + } = ¬ification.item.payload { self.add_async_questions(id, questions); } if replay_kind.is_none() - && let ThreadItem::Reasoning { id, .. } = ¬ification.item + && let ThreadItemPayload::Reasoning { id, .. } = ¬ification.item.payload && self.status_state.reasoning_item_id.as_ref() != Some(id) { return; } - match notification.item { - item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), + match notification.item.payload { + item @ ThreadItemPayload::CommandExecution { .. } => { + self.on_command_execution_completed(item) + } item => self.handle_thread_item( item, notification.turn_id, diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 513f40276dfe..fc794186a353 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -21,7 +21,7 @@ use crate::bottom_pane::VoiceStripPhase; use crate::bottom_pane::VoiceStripState; use crate::history_cell; use crate::motion::MotionMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::UserInput; use codex_protocol::ThreadId; use codex_protocol::models::MessagePhase; @@ -72,7 +72,7 @@ struct PendingRealtimeSpeech { input_generation: u64, thread_id: ThreadId, turn_id: String, - item: ThreadItem, + item: ThreadItemPayload, } #[derive(Clone)] @@ -90,7 +90,7 @@ enum PendingSpeechState { Accepted, } -fn can_retain_realtime_speech(turn_id: &str, item: &ThreadItem) -> bool { +fn can_retain_realtime_speech(turn_id: &str, item: &ThreadItemPayload) -> bool { turn_id.len() <= MAX_PENDING_SPEECH_TURN_ID_BYTES && serde_json::to_vec(item) .is_ok_and(|encoded| encoded.len() <= MAX_PENDING_SPEECH_ITEM_BYTES) @@ -222,11 +222,11 @@ pub(crate) fn realtime_delegation_input(items: &[UserInput]) -> Option<&str> { input.split_once("").map(|(input, _)| input) } -pub(crate) fn is_private_realtime_agent_item(item: &ThreadItem) -> bool { - if matches!(item, ThreadItem::Reasoning { .. }) { +pub(crate) fn is_private_realtime_agent_item(item: &ThreadItemPayload) -> bool { + if matches!(item, ThreadItemPayload::Reasoning { .. }) { return true; } - let ThreadItem::AgentMessage { text, phase, .. } = item else { + let ThreadItemPayload::AgentMessage { text, phase, .. } = item else { return false; }; let text = text.trim_start(); @@ -836,10 +836,10 @@ impl ChatWidget { pub(super) fn complete_realtime_delegated_agent_item( &mut self, turn_id: &str, - item: &ThreadItem, + item: &ThreadItemPayload, from_replay: bool, ) -> bool { - let ThreadItem::AgentMessage { + let ThreadItemPayload::AgentMessage { id: item_id, text, phase, @@ -929,8 +929,12 @@ impl ChatWidget { true } - pub(super) fn speak_completed_realtime_delegation(&mut self, turn_id: &str, item: &ThreadItem) { - let ThreadItem::AgentMessage { + pub(super) fn speak_completed_realtime_delegation( + &mut self, + turn_id: &str, + item: &ThreadItemPayload, + ) { + let ThreadItemPayload::AgentMessage { id: item_id, text, .. } = item else { @@ -1004,7 +1008,7 @@ impl ChatWidget { .iter_mut() .position(|pending| { pending.turn_id == turn_id - && matches!(&pending.item, ThreadItem::AgentMessage { id, .. } if id == item_id) + && matches!(&pending.item, ThreadItemPayload::AgentMessage { id, .. } if id == item_id) && pending.state == PendingSpeechState::AwaitingTurn }); let index = match index { @@ -1060,7 +1064,7 @@ impl ChatWidget { pub(crate) fn take_undelivered_realtime_speech_for_replay( &mut self, - ) -> Vec<(ThreadId, String, ThreadItem)> { + ) -> Vec<(ThreadId, String, ThreadItemPayload)> { let input_generation = self.realtime_conversation.input_generation; self.realtime_conversation .pending_speech @@ -1161,7 +1165,7 @@ impl ChatWidget { &mut self, thread_id: ThreadId, turn_id: String, - item: ThreadItem, + item: ThreadItemPayload, ) { if self.thread_id() == Some(thread_id) { self.handle_thread_item(item, turn_id, super::ThreadItemRenderSource::Live); @@ -1251,7 +1255,7 @@ impl ChatWidget { self.realtime_conversation.pending_speech.retain(|pending| { pending.turn_id != turn_id || pending.state != PendingSpeechState::AwaitingTurn - || !matches!(&pending.item, ThreadItem::AgentMessage { id, .. } if id == item_id) + || !matches!(&pending.item, ThreadItemPayload::AgentMessage { id, .. } if id == item_id) }); } @@ -1523,7 +1527,7 @@ impl ChatWidget { !delivery.captioned && delivery.input_generation == caption_generation && delivery.state != PendingSpeechState::AwaitingTurn - && matches!(&delivery.item, ThreadItem::AgentMessage { text: answer, .. } + && matches!(&delivery.item, ThreadItemPayload::AgentMessage { text: answer, .. } if answer.trim().strip_prefix("[FINAL]").unwrap_or(answer.trim()) .split_whitespace().eq(text.split_whitespace())) }) diff --git a/codex-rs/tui/src/chatwidget/realtime_navigation.rs b/codex-rs/tui/src/chatwidget/realtime_navigation.rs index 5a87af15f800..6dbaaa137b53 100644 --- a/codex-rs/tui/src/chatwidget/realtime_navigation.rs +++ b/codex-rs/tui/src/chatwidget/realtime_navigation.rs @@ -37,7 +37,7 @@ impl ChatWidget { .pending_speech .iter() .filter_map(|pending| match &pending.item { - codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } => { + codex_app_server_protocol::ThreadItemPayload::AgentMessage { text, .. } => { Some(text.as_str()) } _ => None, diff --git a/codex-rs/tui/src/chatwidget/realtime_tests.rs b/codex-rs/tui/src/chatwidget/realtime_tests.rs index 0ada7c233c7e..b77f114f0d2e 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ReasoningTextDeltaNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnItemsView; @@ -69,8 +70,8 @@ pub(crate) fn activate_voice_for_thread(chat: &mut ChatWidget, thread_id: Thread chat.realtime_conversation.latest_input_was_voice = true; } -fn user_item(text: &str) -> ThreadItem { - ThreadItem::UserMessage { +fn user_item(text: &str) -> ThreadItemPayload { + ThreadItemPayload::UserMessage { id: format!("user-{text}"), client_id: None, content: vec![UserInput::Text { @@ -80,8 +81,8 @@ fn user_item(text: &str) -> ThreadItem { } } -fn agent_item(item_id: &str, text: &str, phase: Option) -> ThreadItem { - ThreadItem::AgentMessage { +fn agent_item(item_id: &str, text: &str, phase: Option) -> ThreadItemPayload { + ThreadItemPayload::AgentMessage { id: item_id.to_string(), text: text.to_string(), phase, @@ -91,26 +92,32 @@ fn agent_item(item_id: &str, text: &str, phase: Option) -> ThreadI } } -fn start_item(chat: &mut ChatWidget, thread_id: ThreadId, turn_id: &str, item: ThreadItem) { +fn start_item(chat: &mut ChatWidget, thread_id: ThreadId, turn_id: &str, item: ThreadItemPayload) { chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id.to_string(), + turn_id.to_string(), item, - started_at_ms: 0, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); } -fn complete_item(chat: &mut ChatWidget, thread_id: ThreadId, turn_id: &str, item: ThreadItem) { +fn complete_item( + chat: &mut ChatWidget, + thread_id: ThreadId, + turn_id: &str, + item: ThreadItemPayload, +) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id.to_string(), - turn_id: turn_id.to_string(), + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id.to_string(), + turn_id.to_string(), item, - completed_at_ms: 0, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -119,7 +126,7 @@ fn finish_turn( chat: &mut ChatWidget, thread_id: ThreadId, turn_id: &str, - items: Vec, + items: Vec, status: TurnStatus, ) { chat.handle_server_notification( @@ -128,7 +135,7 @@ fn finish_turn( turn: Turn { id: turn_id.to_string(), root_turn_id: None, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), items_view: TurnItemsView::Summary, status, error: None, diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs index c5b234de6b03..3cc69100761a 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs @@ -1,6 +1,7 @@ //! Replay and captions reconcile once across thread and session changes. use super::*; +use codex_app_server_protocol::ThreadItem; use pretty_assertions::assert_eq; #[tokio::test] @@ -12,30 +13,30 @@ async fn replay_preserves_typed_updates_before_voice_steers_the_turn() { id: "typed-then-voice".into(), root_turn_id: None, items: vec![ - user_item("Typed question"), - agent_item( + ThreadItem::untimed(user_item("Typed question")), + ThreadItem::untimed(agent_item( "typed-update", "Checking the typed request", Some(MessagePhase::Commentary), - ), - ThreadItem::Reasoning { + )), + ThreadItem::untimed(ThreadItemPayload::Reasoning { id: "typed-reasoning".into(), summary: vec!["Typed reasoning summary".into()], content: Vec::new(), - }, - user_item( + }), + ThreadItem::untimed(user_item( "spoken correction", - ), - agent_item( + )), + ThreadItem::untimed(agent_item( "private-update", "Private voice commentary", Some(MessagePhase::Commentary), - ), - ThreadItem::Reasoning { + )), + ThreadItem::untimed(ThreadItemPayload::Reasoning { id: "private-reasoning".into(), summary: vec!["Private voice reasoning".into()], content: Vec::new(), - }, + }), ], items_view: TurnItemsView::Full, status: TurnStatus::Completed, @@ -81,7 +82,7 @@ async fn in_progress_voice_replay_restores_the_late_reasoning_guard() { vec![Turn { id: "saved-voice-turn".into(), root_turn_id: None, - items: vec![voice_request.clone()], + items: vec![ThreadItem::untimed(voice_request.clone())], items_view: TurnItemsView::Full, status: TurnStatus::InProgress, error: None, @@ -92,12 +93,12 @@ async fn in_progress_voice_replay_restores_the_late_reasoning_guard() { ReplayKind::ThreadSnapshot, ); chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id.to_string(), - turn_id: "buffered-voice-turn".into(), - item: voice_request, - started_at_ms: 0, - }), + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id.to_string(), + "buffered-voice-turn".into(), + voice_request, + /*started_at_ms*/ 0, + )), Some(ReplayKind::ThreadSnapshot), ); while events.try_recv().is_ok() {} @@ -116,7 +117,7 @@ async fn in_progress_voice_replay_restores_the_late_reasoning_guard() { &mut chat, thread_id, turn_id, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "late-reasoning".into(), summary: vec!["private after switch".into()], content: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs b/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs index 3afea6c54bea..7a3b2a04ec59 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs @@ -178,7 +178,7 @@ async fn voice_handoff_preserves_started_typed_reasoning_but_hides_new_reasoning &mut chat, thread_id, turn_id, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "typed-reasoning".into(), summary: Vec::new(), content: Vec::new(), @@ -212,7 +212,7 @@ async fn voice_handoff_preserves_started_typed_reasoning_but_hides_new_reasoning &mut chat, thread_id, turn_id, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "typed-reasoning".into(), summary: vec!["Typed beginning and typed tail".into()], content: Vec::new(), @@ -222,7 +222,7 @@ async fn voice_handoff_preserves_started_typed_reasoning_but_hides_new_reasoning &mut chat, thread_id, turn_id, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "private-reasoning".into(), summary: Vec::new(), content: Vec::new(), @@ -242,7 +242,7 @@ async fn voice_handoff_preserves_started_typed_reasoning_but_hides_new_reasoning &mut chat, thread_id, turn_id, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "private-reasoning".into(), summary: vec!["private after handoff".into()], content: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs index cefc45dc858e..94bd4484b373 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs @@ -180,7 +180,7 @@ async fn delegated_async_question_stays_local_and_expires_when_its_turn_ends() { turn_id, user_item("choose"), ); - let answer = ThreadItem::AgentMessage { + let answer = ThreadItemPayload::AgentMessage { id: "question-answer".into(), text: "Which option?".into(), phase: Some(MessagePhase::FinalAnswer), diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs b/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs index d3c8c012f848..01b0a9b0baa4 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs @@ -411,7 +411,7 @@ async fn delegated_reasoning_never_enters_live_history() { &mut chat, thread_id, turn_id, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "reasoning".into(), summary: vec!["private summary".into()], content: vec!["private raw reasoning".into()], @@ -439,7 +439,7 @@ async fn delegated_reasoning_never_enters_live_history() { &mut chat, thread_id, turn_id, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "late-reasoning".into(), summary: vec!["private late summary".into()], content: Vec::new(), @@ -459,7 +459,7 @@ async fn delegated_reasoning_never_enters_live_history() { &mut chat, thread_id, "late-start-turn", - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "late-start-reasoning".into(), summary: vec!["private delayed start".into()], content: Vec::new(), @@ -508,7 +508,7 @@ async fn delegated_reasoning_never_enters_live_history() { } } assert!(!chat.is_realtime_delegated_reasoning_turn(turn_id)); - let typed_reasoning = ThreadItem::Reasoning { + let typed_reasoning = ThreadItemPayload::Reasoning { id: "typed-reasoning".into(), summary: vec!["ordinary summary".into()], content: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index 6c8a7fb7fb59..fbe40068bcf3 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -54,13 +54,17 @@ impl ChatWidget { ServerNotification::ItemCompleted( codex_app_server_protocol::ItemCompletedNotification { turn_id, - item: ThreadItem::Reasoning { id, .. }, + item: + codex_app_server_protocol::ThreadItem { + payload: ThreadItemPayload::Reasoning { id, .. }, + .. + }, .. }, ) => (turn_id, id), ServerNotification::ItemStarted(item) if resumed_turn_id == item.turn_id - && !matches!(item.item, ThreadItem::UserMessage { .. }) + && !matches!(item.item.payload, ThreadItemPayload::UserMessage { .. }) && self.status_state.reasoning_item_id.as_deref() != Some(item.item.id()) => { // A new item closes a trailing snapshot that was already complete at resume. @@ -75,16 +79,16 @@ impl ChatWidget { return false; } self.restore_active_reasoning_item( - codex_app_server_protocol::ItemStartedNotification { - thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn_id: turn_id.clone(), - item: ThreadItem::Reasoning { + codex_app_server_protocol::ItemStartedNotification::new( + self.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn_id.clone(), + ThreadItemPayload::Reasoning { id: item_id.clone(), summary: Vec::new(), content: Vec::new(), }, - started_at_ms: 0, - }, + /*started_at_ms*/ 0, + ), /*parts*/ None, ); true @@ -127,16 +131,16 @@ impl ChatWidget { let Turn { id: turn_id, root_turn_id, - items_view: _, items, status, mut error, started_at, completed_at, duration_ms, + .. } = turn; let delegated = items.iter().any(|item| { - matches!(item, ThreadItem::UserMessage { content, .. } + matches!(&item.payload, ThreadItemPayload::UserMessage { content, .. } if realtime::realtime_delegation_input(content).is_some()) }); if matches!(status, TurnStatus::InProgress) { @@ -151,13 +155,13 @@ impl ChatWidget { let trailing_reasoning_id = (status == TurnStatus::InProgress) .then(|| items.last()) .flatten() - .and_then(|item| match item { - ThreadItem::Reasoning { id, .. } => Some(id.clone()), + .and_then(|item| match &item.payload { + ThreadItemPayload::Reasoning { id, .. } => Some(id.clone()), _ => None, }); let mut replaying_delegation = false; - for item in items { - if matches!(&item, ThreadItem::UserMessage { content, .. } + for item in items.into_iter().map(|entry| entry.payload) { + if matches!(&item, ThreadItemPayload::UserMessage { content, .. } if realtime::realtime_delegation_input(content).is_some()) { replaying_delegation = true; @@ -167,27 +171,29 @@ impl ChatWidget { if replaying_delegation && realtime::is_private_realtime_agent_item(&item) { continue; } - if hidden_nested_review_turn && matches!(item, ThreadItem::UserMessage { .. }) { + if hidden_nested_review_turn + && matches!(item, ThreadItemPayload::UserMessage { .. }) + { continue; } if trailing_reasoning_id.as_deref() == Some(item.id()) - && let ThreadItem::Reasoning { + && let ThreadItemPayload::Reasoning { id, summary, content, } = item { self.restore_active_reasoning_item( - codex_app_server_protocol::ItemStartedNotification { - thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn_id: turn_id.clone(), - item: ThreadItem::Reasoning { + codex_app_server_protocol::ItemStartedNotification::new( + self.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn_id.clone(), + ThreadItemPayload::Reasoning { id, summary: Vec::new(), content: Vec::new(), }, - started_at_ms: 0, - }, + /*started_at_ms*/ 0, + ), Some((summary, content)), ); } else { @@ -243,13 +249,13 @@ impl ChatWidget { pub(crate) fn replay_thread_item( &mut self, - item: ThreadItem, + item: ThreadItemPayload, turn_id: String, replay_kind: ReplayKind, ) { match item { // Snapshots contain the completed item, without the live start that renders its diff. - ThreadItem::FileChange { + ThreadItemPayload::FileChange { changes, status: codex_app_server_protocol::PatchApplyStatus::Completed, .. @@ -266,14 +272,14 @@ impl ChatWidget { pub(super) fn handle_thread_item( &mut self, - item: ThreadItem, + item: ThreadItemPayload, turn_id: String, render_source: ThreadItemRenderSource, ) { let from_replay = render_source.is_replay(); let replay_kind = render_source.replay_kind(); match item { - ThreadItem::UserMessage { + ThreadItemPayload::UserMessage { content, client_id, .. } => { if let Some(replies) = crate::async_question_reply::parse_input(&content) { @@ -296,7 +302,7 @@ impl ChatWidget { &turn_id, ); } - ThreadItem::AgentMessage { + ThreadItemPayload::AgentMessage { id, text, phase, @@ -307,7 +313,7 @@ impl ChatWidget { } => { if self.complete_realtime_delegated_agent_item( &turn_id, - &ThreadItem::AgentMessage { + &ThreadItemPayload::AgentMessage { id: id.clone(), text: text.clone(), phase: phase.clone(), @@ -348,8 +354,8 @@ impl ChatWidget { from_replay, ); } - ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text), - ThreadItem::Reasoning { + ThreadItemPayload::Plan { text, .. } => self.on_plan_item_completed(text), + ThreadItemPayload::Reasoning { id, summary, content, @@ -382,29 +388,31 @@ impl ChatWidget { } self.on_agent_reasoning_final(); } - item @ ThreadItem::CommandExecution { + item @ ThreadItemPayload::CommandExecution { status: codex_app_server_protocol::CommandExecutionStatus::InProgress, .. } => self.on_command_execution_started(item), - item @ ThreadItem::CommandExecution { + item @ ThreadItemPayload::CommandExecution { source: ExecCommandSource::Agent | ExecCommandSource::UnifiedExecStartup, status: codex_app_server_protocol::CommandExecutionStatus::Completed | codex_app_server_protocol::CommandExecutionStatus::Failed, .. } if from_replay => self.handle_command_execution_completed_now(item), - item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), - ThreadItem::FileChange { + item @ ThreadItemPayload::CommandExecution { .. } => { + self.on_command_execution_completed(item) + } + ThreadItemPayload::FileChange { status: codex_app_server_protocol::PatchApplyStatus::InProgress, .. } => {} - item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), - item @ ThreadItem::McpToolCall { + item @ ThreadItemPayload::FileChange { .. } => self.on_file_change_completed(item), + item @ ThreadItemPayload::McpToolCall { status: codex_app_server_protocol::McpToolCallStatus::InProgress, .. } => self.on_mcp_tool_call_started(item), - item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), - ThreadItem::WebSearch(item) => { + item @ ThreadItemPayload::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), + ThreadItemPayload::WebSearch(item) => { self.on_web_search_begin(item.id.clone()); self.on_web_search_end( item.id, @@ -413,10 +421,10 @@ impl ChatWidget { .unwrap_or(codex_app_server_protocol::WebSearchAction::Other), ); } - ThreadItem::ImageView { id, path } => { + ThreadItemPayload::ImageView { id, path } => { self.on_view_image_tool_call(id, path); } - ThreadItem::ImageGeneration(item) => { + ThreadItemPayload::ImageGeneration(item) => { self.on_image_generation_end( item.id, item.status, @@ -424,18 +432,18 @@ impl ChatWidget { item.saved_path, ); } - ThreadItem::EnteredReviewMode { review, .. } => { + ThreadItemPayload::EnteredReviewMode { review, .. } => { if from_replay { self.enter_review_mode_with_hint(review, /*from_replay*/ true); } } - ThreadItem::ExitedReviewMode { .. } => { + ThreadItemPayload::ExitedReviewMode { .. } => { self.exit_review_mode_after_item(); } - ThreadItem::ContextCompaction { id } => { + ThreadItemPayload::ContextCompaction { id } => { self.on_context_compaction_completed(&id, from_replay); } - ThreadItem::FunctionCallOutput { + ThreadItemPayload::FunctionCallOutput { name, namespace, output, @@ -455,8 +463,8 @@ impl ChatWidget { )); } } - ThreadItem::HookPrompt { .. } => {} - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::HookPrompt { .. } => {} + ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -466,7 +474,7 @@ impl ChatWidget { model, reasoning_effort, agents_states, - } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { + } => self.on_collab_agent_tool_call(ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -477,9 +485,9 @@ impl ChatWidget { reasoning_effort, agents_states, }), - item @ ThreadItem::SubAgentActivity { .. } => self.on_sub_agent_activity(item), - item @ ThreadItem::DynamicToolCall { .. } => self.on_dynamic_tool_item(item), - ThreadItem::Sleep(_) => {} + item @ ThreadItemPayload::SubAgentActivity { .. } => self.on_sub_agent_activity(item), + item @ ThreadItemPayload::DynamicToolCall { .. } => self.on_dynamic_tool_item(item), + ThreadItemPayload::Sleep(_) => {} } if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() { diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 3e78f3f4bd36..90c06a91d249 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -355,8 +355,8 @@ impl ChatWidget { self.transcript.reset_turn_flags(); for turn in retained_turns { let mut replaying_delegation = false; - for item in &turn.items { - if matches!(item, ThreadItem::UserMessage { content, .. } + for item in turn.item_payloads() { + if matches!(item, ThreadItemPayload::UserMessage { content, .. } if realtime::realtime_delegation_input(content).is_some()) { replaying_delegation = true; @@ -365,11 +365,11 @@ impl ChatWidget { continue; } let (markdown, source) = match item { - ThreadItem::AgentMessage { text, .. } => ( + ThreadItemPayload::AgentMessage { text, .. } => ( parse_assistant_markdown(text, self.config.cwd.as_path()).visible_markdown, text, ), - ThreadItem::Plan { text, .. } => (text.clone(), text), + ThreadItemPayload::Plan { text, .. } => (text.clone(), text), _ => continue, }; if !markdown.trim().is_empty() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1cc51a16460f..aae8906ab930 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -102,7 +102,7 @@ pub(super) use codex_app_server_protocol::ServerNotification; pub(super) use codex_app_server_protocol::SkillMetadata; pub(super) use codex_app_server_protocol::SkillSummary; pub(super) use codex_app_server_protocol::ThreadClosedNotification; -pub(super) use codex_app_server_protocol::ThreadItem as AppServerThreadItem; +pub(super) use codex_app_server_protocol::ThreadItemPayload as AppServerThreadItemPayload; pub(super) use codex_app_server_protocol::ToolRequestUserInputOption; pub(super) use codex_app_server_protocol::ToolRequestUserInputParams; pub(super) use codex_app_server_protocol::ToolRequestUserInputQuestion; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 7017aca6c147..70f70c3cf343 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,7 @@ use super::helpers::drain_insert_history_transcript; use super::*; use codex_app_server_protocol::AuthRecoveryNotification; +use codex_app_server_protocol::ThreadItem; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; use pretty_assertions::assert_eq; @@ -734,11 +735,10 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { ); chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: AppServerThreadItem::CollabAgentToolCall { + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::InProgress, @@ -749,15 +749,15 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { reasoning_effort: Some(ReasoningEffortConfig::High), agents_states: HashMap::new(), }, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::CollabAgentToolCall { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::Completed, @@ -774,7 +774,9 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { }, )]), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -810,11 +812,10 @@ async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered assert!(lines_to_single_string(&inserted[0]).contains("Hi, are you there?")); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::UserMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { @@ -822,7 +823,9 @@ async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered text_elements: Vec::new(), }], }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -834,11 +837,10 @@ async fn live_app_server_user_message_omits_unsupported_media() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::UserMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![ @@ -854,7 +856,9 @@ async fn live_app_server_user_message_omits_unsupported_media() { }, ], }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -895,7 +899,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); - let item = AppServerThreadItem::AgentMessage { + let item = AppServerThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: "Yes. What do you need?".to_string(), phase: Some(MessagePhase::FinalAnswer), @@ -904,12 +908,13 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( questions: None, }; chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: item.clone(), - }), + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + item.clone(), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -925,7 +930,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Summary, - items: vec![item], + items: vec![ThreadItem::untimed(item)], status: AppServerTurnStatus::Completed, error: None, started_at: None, @@ -1221,7 +1226,7 @@ async fn startup_config_warning_is_not_repeated_by_thread() { async fn live_app_server_file_change_item_started_preserves_changes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let item = AppServerThreadItem::FileChange { + let item = AppServerThreadItemPayload::FileChange { id: "patch-1".to_string(), changes: vec![FileUpdateChange { path: "foo.txt".to_string(), @@ -1231,23 +1236,23 @@ async fn live_app_server_file_change_item_started_preserves_changes() { status: AppServerPatchApplyStatus::InProgress, }; chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: item.clone(), - }), + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + item.clone(), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected patch history to be rendered"); let transcript = lines_to_single_string(cells.last().expect("patch cell")); - let AppServerThreadItem::FileChange { id, changes, .. } = item else { + let AppServerThreadItemPayload::FileChange { id, changes, .. } = item else { unreachable!() }; chat.replay_thread_item( - AppServerThreadItem::FileChange { + AppServerThreadItemPayload::FileChange { id, changes, status: AppServerPatchApplyStatus::Completed, @@ -1272,11 +1277,10 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { shlex::try_join(["/bin/zsh", "-lc", script]).expect("round-trippable shell wrapper"); chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: AppServerThreadItem::CommandExecution { + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "cmd-1".to_string(), @@ -1294,15 +1298,15 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { exit_code: None, duration_ms: None, }, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::CommandExecution { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "cmd-1".to_string(), @@ -1320,7 +1324,9 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { exit_code: Some(0), duration_ms: Some(5), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -1391,7 +1397,7 @@ async fn live_app_server_command_output_delta_transcript_snapshot() { #[tokio::test] async fn live_app_server_sub_agent_activity_renders_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let activity = AppServerThreadItem::SubAgentActivity { + let activity = AppServerThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "activity-1".to_string(), @@ -1401,21 +1407,22 @@ async fn live_app_server_sub_agent_activity_renders_once() { }; chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: activity.clone(), - }), + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + activity.clone(), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: activity, - }), + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + activity, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -1446,11 +1453,10 @@ async fn live_app_server_collab_wait_items_render_history() { ); chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: AppServerThreadItem::CollabAgentToolCall { + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, status: AppServerCollabAgentToolCallStatus::InProgress, @@ -1464,16 +1470,16 @@ async fn live_app_server_collab_wait_items_render_history() { reasoning_effort: None, agents_states: HashMap::new(), }, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::CollabAgentToolCall { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, status: AppServerCollabAgentToolCallStatus::Completed, @@ -1502,7 +1508,9 @@ async fn live_app_server_collab_wait_items_render_history() { ), ]), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -1523,11 +1531,10 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo ThreadId::from_string("019cff70-2599-75e2-af72-b91781b41a8e").expect("valid thread id"); chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: AppServerThreadItem::CollabAgentToolCall { + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::InProgress, @@ -1538,16 +1545,16 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo reasoning_effort: Some(ReasoningEffortConfig::High), agents_states: HashMap::new(), }, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::CollabAgentToolCall { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::Completed, @@ -1564,7 +1571,9 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo }, )]), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -1697,7 +1706,7 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() { /*error*/ None, ); completed_turn.items_view = codex_app_server_protocol::TurnItemsView::Summary; - completed_turn.items = vec![AppServerThreadItem::AgentMessage { + completed_turn.items = vec![ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage { id: "msg-1".to_string(), text: concat!( "The transport kept this.\nAnd dropped this.\n\n", @@ -1711,7 +1720,7 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() { title: "Which way?".into(), options: None, }]), - }]; + })]; chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: "thread-1".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/compaction_tests.rs b/codex-rs/tui/src/chatwidget/tests/compaction_tests.rs index 9143e7a5390a..291dc844f409 100644 --- a/codex-rs/tui/src/chatwidget/tests/compaction_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/compaction_tests.rs @@ -9,21 +9,22 @@ fn normalize_compaction_snapshot(text: String) -> String { } fn compaction_started(id: &str) -> ServerNotification { - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: chrono::Utc::now().timestamp_millis(), - item: AppServerThreadItem::ContextCompaction { id: id.to_string() }, - }) + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::ContextCompaction { id: id.to_string() }, + chrono::Utc::now().timestamp_millis(), + )) } fn compaction_completed(id: &str) -> ServerNotification { - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::ContextCompaction { id: id.to_string() }, - }) + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::ContextCompaction { id: id.to_string() }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )) } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs b/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs index 6838e3a6a4a7..44e5099d1f02 100644 --- a/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/completion_styling_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::clock_format::ClockFormat; use chrono::Local; use chrono::TimeZone; +use codex_app_server_protocol::ThreadItem; use pretty_assertions::assert_eq; const COMPLETED_AT: i64 = 1_700_000_000; @@ -11,14 +12,16 @@ const COMPLETED_AT: i64 = 1_700_000_000; fn completed_turn(duration_ms: Option, completed_at: Option) -> AppServerTurn { AppServerTurn { completed_at, - items: vec![AppServerThreadItem::AgentMessage { - id: "answer-1".to_string(), - text: "The change is ready.".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: None, - delivery: None, - questions: None, - }], + items: vec![ThreadItem::untimed( + AppServerThreadItemPayload::AgentMessage { + id: "answer-1".to_string(), + text: "The change is ready.".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + delivery: None, + questions: None, + }, + )], ..app_server_turn( "turn-1", AppServerTurnStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs b/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs index 4e7dca4ac212..e291b4206c0f 100644 --- a/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/computer_activity_tests.rs @@ -4,8 +4,8 @@ use pretty_assertions::assert_eq; fn computer_item( id: &str, status: codex_app_server_protocol::McpToolCallStatus, -) -> AppServerThreadItem { - AppServerThreadItem::McpToolCall { +) -> AppServerThreadItemPayload { + AppServerThreadItemPayload::McpToolCall { id: id.to_string(), server: "cua_repl".to_string(), tool: "js".to_string(), @@ -83,7 +83,7 @@ async fn computer_activity_keeps_reasoning_in_order_live_and_replayed() { let summary = format!("{part} action {id}"); if replay { chat.replay_thread_item( - AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: summary.clone(), summary: vec![summary], content: Vec::new(), @@ -133,7 +133,7 @@ async fn computer_activity_preserves_boundaries_and_expanded_output() { assert!(lines_to_single_string(&cells[0]).contains("Used computer · 1 action")); chat.on_mcp_tool_call_completed(computer_item("2", McpToolCallStatus::Completed)); let mut other = computer_item("other", McpToolCallStatus::Completed); - if let AppServerThreadItem::McpToolCall { server, .. } = &mut other { + if let AppServerThreadItemPayload::McpToolCall { server, .. } = &mut other { *server = "another_server".to_string(); } chat.on_mcp_tool_call_completed(other); diff --git a/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs b/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs index 72edc6cac4b2..b98edb77a4fe 100644 --- a/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs @@ -12,9 +12,9 @@ use ratatui::layout::Rect; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -fn dynamic_item(id: &str, status: DynamicToolCallStatus) -> AppServerThreadItem { +fn dynamic_item(id: &str, status: DynamicToolCallStatus) -> AppServerThreadItemPayload { let completed = status != DynamicToolCallStatus::InProgress; - AppServerThreadItem::DynamicToolCall { + AppServerThreadItemPayload::DynamicToolCall { id: id.to_string(), namespace: Some("test".to_string()), tool: "lookup".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 4e1127ba464a..5d2f4693dcb2 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1,4 +1,5 @@ use super::*; +use codex_app_server_protocol::ThreadItem; use pretty_assertions::assert_eq; #[tokio::test] @@ -52,17 +53,18 @@ async fn replayed_command_completion_preserves_tracking_without_duplicate_starts chat.on_task_started(); let mut item = begin_unified_exec_startup(&mut chat, "call-replay", "process-replay", "cat replay"); - if let AppServerThreadItem::CommandExecution { status, .. } = &mut item { + if let AppServerThreadItemPayload::CommandExecution { status, .. } = &mut item { *status = AppServerCommandExecutionStatus::Completed; } chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: String::new(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + String::new(), + "turn-1".to_string(), item, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), Some(ReplayKind::ThreadSnapshot), ); @@ -95,7 +97,7 @@ async fn replayed_completion_preserves_unrelated_running_command() { /*animations_enabled*/ false, ))); } - if let AppServerThreadItem::CommandExecution { + if let AppServerThreadItemPayload::CommandExecution { id, command, status, @@ -238,7 +240,7 @@ async fn adjacent_exploration_groups_across_reasoning_live_and_replayed() { ("after-message", "cat final.txt", 0), ] { let command = vec!["bash".to_string(), "-lc".to_string(), script.to_string()]; - let mut item = AppServerThreadItem::CommandExecution { + let mut item = AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: id.to_string(), @@ -262,7 +264,7 @@ async fn adjacent_exploration_groups_across_reasoning_live_and_replayed() { if !replay { handle_exec_begin(&mut chat, item.clone()); } - if let AppServerThreadItem::CommandExecution { status, .. } = &mut item { + if let AppServerThreadItemPayload::CommandExecution { status, .. } = &mut item { *status = if exit_code == 0 { AppServerCommandExecutionStatus::Completed } else { @@ -278,7 +280,7 @@ async fn adjacent_exploration_groups_across_reasoning_live_and_replayed() { for summary in ["Inspecting the page", "Checking its contents"] { if replay { chat.replay_thread_item( - AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: summary.to_string(), summary: vec![summary.to_string()], content: Vec::new(), @@ -332,8 +334,8 @@ async fn adjacent_exploration_groups_across_reasoning_live_and_replayed() { async fn replayed_commands_preserve_individual_output_and_failure_status() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let cwd = chat.config.cwd.clone(); - let replayed_command = - |id: &str, output: &str, source: ExecCommandSource| AppServerThreadItem::CommandExecution { + let replayed_command = |id: &str, output: &str, source: ExecCommandSource| { + AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: id.to_string(), @@ -348,21 +350,22 @@ async fn replayed_commands_preserve_individual_output_and_failure_status() { aggregated_output: Some(format!("{output}\n")), exit_code: Some(0), duration_ms: Some(5), - }; + } + }; let mut declined = replayed_command("call-declined", "declined", ExecCommandSource::Agent); let mut failed = replayed_command( "call-failed", "failure", ExecCommandSource::UnifiedExecStartup, ); - if let AppServerThreadItem::CommandExecution { + if let AppServerThreadItemPayload::CommandExecution { status, exit_code, .. } = &mut failed { *status = AppServerCommandExecutionStatus::Failed; *exit_code = Some(7); } - if let AppServerThreadItem::CommandExecution { + if let AppServerThreadItemPayload::CommandExecution { status, exit_code, .. } = &mut declined { @@ -371,14 +374,18 @@ async fn replayed_commands_preserve_individual_output_and_failure_status() { } let turn = AppServerTurn { items: vec![ - replayed_command("call-first", "first", ExecCommandSource::Agent), - replayed_command( + ThreadItem::untimed(replayed_command( + "call-first", + "first", + ExecCommandSource::Agent, + )), + ThreadItem::untimed(replayed_command( "call-second", "second", ExecCommandSource::UnifiedExecStartup, - ), - failed, - declined, + )), + ThreadItem::untimed(failed), + ThreadItem::untimed(declined), ], ..app_server_turn( "turn-1", @@ -530,7 +537,7 @@ async fn exec_end_without_begin_uses_event_command() { let cwd = chat.config.cwd.clone(); handle_exec_end( &mut chat, - AppServerThreadItem::CommandExecution { + AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "call-orphan".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 75f63ce1ea22..3ac0a5b8114b 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -632,20 +632,19 @@ pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into pub(super) fn handle_agent_reasoning_started(chat: &mut ChatWidget, id: impl Into) { chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id(chat), - turn_id: chat - .turn_lifecycle + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id(chat), + chat.turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), - started_at_ms: 0, - item: AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: id.into(), summary: Vec::new(), content: Vec::new(), }, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -675,46 +674,45 @@ pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) { handle_agent_reasoning_started(chat, "reasoning-1"); } chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id(chat), - turn_id: chat - .turn_lifecycle + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id(chat), + chat.turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), - completed_at_ms: 0, - item: AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: Vec::new(), content: Vec::new(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Into) { chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id(chat), - turn_id: chat - .turn_lifecycle + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id(chat), + chat.turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), - started_at_ms: 0, - item: AppServerThreadItem::EnteredReviewMode { + AppServerThreadItemPayload::EnteredReviewMode { id: "review-start".to_string(), review: review.into(), }, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); } pub(super) fn replay_entered_review_mode(chat: &mut ChatWidget, review: impl Into) { chat.replay_thread_item( - AppServerThreadItem::EnteredReviewMode { + AppServerThreadItemPayload::EnteredReviewMode { id: "review-start".to_string(), review: review.into(), }, @@ -725,19 +723,19 @@ pub(super) fn replay_entered_review_mode(chat: &mut ChatWidget, review: impl Int pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id(chat), - turn_id: chat - .turn_lifecycle + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id(chat), + chat.turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), - completed_at_ms: 0, - item: AppServerThreadItem::ExitedReviewMode { + AppServerThreadItemPayload::ExitedReviewMode { id: "review-end".to_string(), review: String::new(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -786,16 +784,16 @@ pub(super) fn handle_patch_apply_begin( changes: HashMap, ) { chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id(chat), - turn_id: turn_id.into(), - started_at_ms: 0, - item: AppServerThreadItem::FileChange { + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id(chat), + turn_id.into(), + AppServerThreadItemPayload::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), status: AppServerPatchApplyStatus::InProgress, }, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -808,16 +806,17 @@ pub(super) fn handle_patch_apply_end( status: AppServerPatchApplyStatus, ) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id(chat), - turn_id: turn_id.into(), - completed_at_ms: 0, - item: AppServerThreadItem::FileChange { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id(chat), + turn_id.into(), + AppServerThreadItemPayload::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), status, }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -828,15 +827,16 @@ pub(super) fn handle_view_image_tool_call( path: impl Into, ) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id(chat), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::ImageView { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id(chat), + "turn-1".to_string(), + AppServerThreadItemPayload::ImageView { id: call_id.into(), path: path.into(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -849,11 +849,10 @@ pub(super) fn handle_image_generation_end( saved_path: Option, ) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id(chat), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::ImageGeneration(ImageGenerationItem { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id(chat), + "turn-1".to_string(), + AppServerThreadItemPayload::ImageGeneration(ImageGenerationItem { id: call_id.into(), status: status.into(), revised_prompt, @@ -867,7 +866,9 @@ pub(super) fn handle_image_generation_end( imagegen_request_id: None, generation_id: None, }), - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -879,7 +880,7 @@ pub(super) fn replay_user_message_inputs( replay_kind: ReplayKind, ) { chat.replay_thread_item( - AppServerThreadItem::UserMessage { + AppServerThreadItemPayload::UserMessage { id: item_id.to_string(), client_id: None, content, @@ -913,7 +914,7 @@ pub(super) fn replay_agent_message( replay_kind: ReplayKind, ) { chat.replay_thread_item( - AppServerThreadItem::AgentMessage { + AppServerThreadItemPayload::AgentMessage { id: item_id.to_string(), text: text.into(), phase: Some(MessagePhase::FinalAnswer), @@ -965,7 +966,7 @@ pub(super) fn begin_exec_with_source( call_id: &str, raw_cmd: &str, source: ExecCommandSource, -) -> AppServerThreadItem { +) -> AppServerThreadItemPayload { // Build the full command vec and parse it using core's parser, // then convert to protocol variants for the event payload. let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; @@ -973,7 +974,7 @@ pub(super) fn begin_exec_with_source( .into_iter() .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) .collect(); - let item = AppServerThreadItem::CommandExecution { + let item = AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: call_id.to_string(), @@ -998,9 +999,9 @@ pub(super) fn begin_unified_exec_startup( call_id: &str, process_id: &str, raw_cmd: &str, -) -> AppServerThreadItem { +) -> AppServerThreadItemPayload { let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let item = AppServerThreadItem::CommandExecution { + let item = AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: call_id.to_string(), @@ -1020,18 +1021,17 @@ pub(super) fn begin_unified_exec_startup( item } -pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem) { +pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItemPayload) { chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: thread_id(chat), - turn_id: chat - .turn_lifecycle + ServerNotification::ItemStarted(ItemStartedNotification::new( + thread_id(chat), + chat.turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), - started_at_ms: 0, item, - }), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -1067,11 +1067,10 @@ pub(super) fn complete_assistant_message( phase: Option, ) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::AgentMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), + "turn-1".to_string(), + AppServerThreadItemPayload::AgentMessage { id: item_id.to_string(), text: text.to_string(), phase, @@ -1079,7 +1078,9 @@ pub(super) fn complete_assistant_message( delivery: None, questions: None, }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -1114,16 +1115,17 @@ pub(super) fn complete_user_message_for_inputs( content: Vec, ) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::UserMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), + "turn-1".to_string(), + AppServerThreadItemPayload::UserMessage { id: item_id.to_string(), client_id: None, content, }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -1205,13 +1207,13 @@ pub(super) fn begin_exec( chat: &mut ChatWidget, call_id: &str, raw_cmd: &str, -) -> AppServerThreadItem { +) -> AppServerThreadItemPayload { begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) } pub(super) fn end_exec( chat: &mut ChatWidget, - begin_item: AppServerThreadItem, + begin_item: AppServerThreadItemPayload, stdout: &str, stderr: &str, exit_code: i32, @@ -1221,7 +1223,7 @@ pub(super) fn end_exec( } else { format!("{stdout}{stderr}") }; - let AppServerThreadItem::CommandExecution { + let AppServerThreadItemPayload::CommandExecution { id, command, cwd, @@ -1237,7 +1239,7 @@ pub(super) fn end_exec( }; handle_exec_end( chat, - AppServerThreadItem::CommandExecution { + AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id, @@ -1260,18 +1262,18 @@ pub(super) fn end_exec( ); } -pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem) { +pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItemPayload) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: thread_id(chat), - turn_id: chat - .turn_lifecycle + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + thread_id(chat), + chat.turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), - completed_at_ms: 0, item, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } diff --git a/codex-rs/tui/src/chatwidget/tests/history_projection.rs b/codex-rs/tui/src/chatwidget/tests/history_projection.rs index 30674a7167c9..edd451f65cc1 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_projection.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_projection.rs @@ -5,6 +5,7 @@ use crate::thread_transcript::RawReasoningVisibility; use crate::thread_transcript::thread_items_to_transcript_cells; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::ThreadItem; use pretty_assertions::assert_eq; #[tokio::test] @@ -12,7 +13,7 @@ async fn older_tool_projection_matches_initial_replay() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; drain_insert_history(&mut rx); let command = "cargo check"; - let mut items = vec![AppServerThreadItem::CommandExecution { + let mut items = vec![AppServerThreadItemPayload::CommandExecution { id: "command".to_string(), plugin_id: None, script_path: None, @@ -38,7 +39,7 @@ async fn older_tool_projection_matches_initial_replay() { // Completion-only replay must group consecutive exploration just like older-page projection. for name in ["one.rs", "two.rs"] { let command = format!("cat {name}"); - items.push(AppServerThreadItem::CommandExecution { + items.push(AppServerThreadItemPayload::CommandExecution { id: name.to_string(), plugin_id: None, script_path: None, @@ -69,13 +70,13 @@ async fn older_tool_projection_matches_initial_replay() { ("cua_repl", "5"), ] { if id == "2" { - items.push(AppServerThreadItem::Reasoning { + items.push(AppServerThreadItemPayload::Reasoning { id: "computer-reasoning".to_string(), summary: vec!["**Inspecting the page**".to_string()], content: Vec::new(), }); } - items.push(AppServerThreadItem::McpToolCall { + items.push(AppServerThreadItemPayload::McpToolCall { id: id.to_string(), server: server.to_string(), tool: "js".to_string(), @@ -99,14 +100,14 @@ async fn older_tool_projection_matches_initial_replay() { // inside the active exploration group until the completed turn commits it. for (index, id) in [(1, "trailing-read-one"), (2, "trailing-read-two")] { if index == 2 { - items.push(AppServerThreadItem::Reasoning { + items.push(AppServerThreadItemPayload::Reasoning { id: "exploration-reasoning".to_string(), summary: vec!["**Checking the implementation**".to_string()], content: Vec::new(), }); } let mut read = items[index].clone(); - if let AppServerThreadItem::CommandExecution { id: read_id, .. } = &mut read { + if let AppServerThreadItemPayload::CommandExecution { id: read_id, .. } = &mut read { *read_id = id.to_string(); } items.push(read); @@ -120,7 +121,7 @@ async fn older_tool_projection_matches_initial_replay() { ); chat.replay_thread_turns( vec![AppServerTurn { - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), ..app_server_turn( "turn", AppServerTurnStatus::Completed, @@ -185,7 +186,7 @@ async fn snapshot_formatter_reasoning_matches_compact_and_detailed_replay() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; drain_insert_history(&mut rx); chat.config.show_raw_agent_reasoning = visibility == RawReasoningVisibility::Visible; - let item = AppServerThreadItem::Reasoning { + let item = AppServerThreadItemPayload::Reasoning { id: "reasoning".to_string(), summary: vec![ "**Plan**\n\nRead [src/main.rs](src/main.rs:3).".to_string(), @@ -222,7 +223,7 @@ async fn snapshot_formatter_reasoning_matches_compact_and_detailed_replay() { )); chat.replay_thread_turns( vec![AppServerTurn { - items: vec![item], + items: vec![ThreadItem::untimed(item)], ..app_server_turn( "turn", AppServerTurnStatus::Completed, @@ -250,7 +251,7 @@ fn raw_reasoning_keeps_its_own_heading() { let projected = thread_items_to_transcript_cells( /*thread_id*/ None, &codex_utils_absolute_path::AbsolutePathBuf::current_dir().unwrap(), - [AppServerThreadItem::Reasoning { + [AppServerThreadItemPayload::Reasoning { id: "raw".into(), summary: Vec::new(), content: vec!["**Raw investigation**\nKeep this heading and its details.".into()], @@ -312,21 +313,22 @@ async fn snapshot_formatter_live_and_buffered_patch_notifications_render_once() let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; drain_insert_history(&mut rx); chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread".to_string(), - turn_id: "turn".to_string(), - item: patch_item(AppServerPatchApplyStatus::InProgress), - started_at_ms: 0, - }), + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread".to_string(), + "turn".to_string(), + patch_item(AppServerPatchApplyStatus::InProgress), + /*started_at_ms*/ 0, + )), replay_kind, ); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread".to_string(), - turn_id: "turn".to_string(), - item: patch_item(AppServerPatchApplyStatus::Completed), - completed_at_ms: 0, - }), + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread".to_string(), + "turn".to_string(), + patch_item(AppServerPatchApplyStatus::Completed), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), replay_kind, ); let replayed = take_history_cells(&mut rx); @@ -343,7 +345,7 @@ async fn snapshot_formatter_empty_success_and_failed_patch_keep_their_outcomes() let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; drain_insert_history(&mut rx); chat.replay_thread_item( - AppServerThreadItem::FileChange { + AppServerThreadItemPayload::FileChange { id: "empty".to_string(), changes: Vec::new(), status: AppServerPatchApplyStatus::Completed, @@ -363,8 +365,8 @@ async fn snapshot_formatter_empty_success_and_failed_patch_keep_their_outcomes() "); } -fn patch_item(status: AppServerPatchApplyStatus) -> AppServerThreadItem { - AppServerThreadItem::FileChange { +fn patch_item(status: AppServerPatchApplyStatus) -> AppServerThreadItemPayload { + AppServerThreadItemPayload::FileChange { id: "patch".to_string(), changes: vec![FileUpdateChange { path: "sample.txt".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 8e1e0a525bc7..434aaa19cf81 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -4,6 +4,7 @@ use crate::app_event::HistoryLookupResponse; use codex_app_server_protocol::ImageReference; use codex_app_server_protocol::NetworkAccess; use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ThreadItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; @@ -131,14 +132,16 @@ async fn replayed_failed_turns_preserve_overload_warnings_between_retries() { let prompt = "The workspace also looks super confusing with its separator."; let error_message = "Selected model is at capacity. Please try a different model."; let failed_turn = |turn_id: &str, item_id: &str| AppServerTurn { - items: vec![AppServerThreadItem::UserMessage { - id: item_id.to_string(), - client_id: None, - content: vec![AppServerUserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }], - }], + items: vec![ThreadItem::untimed( + AppServerThreadItemPayload::UserMessage { + id: item_id.to_string(), + client_id: None, + content: vec![AppServerUserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + }, + )], ..app_server_turn( turn_id, AppServerTurnStatus::Failed, @@ -307,7 +310,7 @@ async fn replayed_review_prompt_does_not_seed_composer_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; chat.replay_thread_item( - AppServerThreadItem::EnteredReviewMode { + AppServerThreadItemPayload::EnteredReviewMode { id: "review-start".to_string(), review: "changes against main".to_string(), }, @@ -330,7 +333,7 @@ async fn replayed_review_prompt_does_not_seed_composer_history() { async fn replayed_delegated_tool_output_is_attributed_without_seeding_composer_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let item = AppServerThreadItem::FunctionCallOutput { + let item = AppServerThreadItemPayload::FunctionCallOutput { id: "delegation-1".to_string(), name: "send_message_to_thread".to_string(), namespace: Some("codex_tui".to_string()), @@ -374,7 +377,7 @@ async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history() let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let review_hint = "current changes"; let review_prompt = "Review the current code changes (staged, unstaged, and untracked files)."; - let user_message = |id: &str, text: &str| AppServerThreadItem::UserMessage { + let user_message = |id: &str, text: &str| AppServerThreadItemPayload::UserMessage { id: id.to_string(), client_id: None, content: vec![AppServerUserInput::Text { @@ -384,14 +387,14 @@ async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history() }; let review_marker = |turn_id: &str| AppServerTurn { items: vec![ - AppServerThreadItem::EnteredReviewMode { + ThreadItem::untimed(AppServerThreadItemPayload::EnteredReviewMode { id: format!("{turn_id}-start"), review: review_hint.to_string(), - }, - AppServerThreadItem::ExitedReviewMode { + }), + ThreadItem::untimed(AppServerThreadItemPayload::ExitedReviewMode { id: format!("{turn_id}-end"), review: "review complete".to_string(), - }, + }), ], ..app_server_turn( turn_id, @@ -406,8 +409,8 @@ async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history() review_marker("turn-review-before-steer"), AppServerTurn { items: vec![ - user_message("interrupted-prompt", review_hint), - user_message("interrupted-steer", review_hint), + ThreadItem::untimed(user_message("interrupted-prompt", review_hint)), + ThreadItem::untimed(user_message("interrupted-steer", review_hint)), ], completed_at: Some(1), ..app_server_turn( @@ -420,16 +423,16 @@ async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history() review_marker("turn-review"), AppServerTurn { items: vec![ - user_message("review-prompt-1", review_prompt), - user_message("review-prompt-2", review_prompt), - AppServerThreadItem::AgentMessage { + ThreadItem::untimed(user_message("review-prompt-1", review_prompt)), + ThreadItem::untimed(user_message("review-prompt-2", review_prompt)), + ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage { id: "review-result".to_string(), text: "review result is retained".to_string(), phase: Some(MessagePhase::FinalAnswer), memory_citation: None, delivery: None, questions: None, - }, + }), ], ..app_server_turn( "turn-review-child", @@ -1000,15 +1003,15 @@ async fn prompt_edit_stops_streaming_without_submitting_queued_input() { let retained_turn = AppServerTurn { items: vec![ - AppServerThreadItem::AgentMessage { + ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage { id: "retained-response".to_string(), text: "retained response".to_string(), phase: Some(MessagePhase::FinalAnswer), memory_citation: None, delivery: None, questions: None, - }, - AppServerThreadItem::UserMessage { + }), + ThreadItem::untimed(AppServerThreadItemPayload::UserMessage { id: "voice-steer".to_string(), client_id: None, content: vec![UserInput::Text { @@ -1016,15 +1019,15 @@ async fn prompt_edit_stops_streaming_without_submitting_queued_input() { .to_string(), text_elements: Vec::new(), }], - }, - AppServerThreadItem::AgentMessage { + }), + ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage { id: "private-commentary".to_string(), text: "hidden voice commentary".to_string(), phase: Some(MessagePhase::Commentary), memory_citation: None, delivery: None, questions: None, - }, + }), ], ..app_server_turn( "retained-turn", @@ -1262,7 +1265,7 @@ async fn replayed_reasoning_item_preserves_summary_parts_and_hides_raw_reasoning let _ = drain_insert_history(&mut rx); chat.replay_thread_item( - AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: vec![ "**Plan**\n\ndone".to_string(), @@ -1314,7 +1317,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let _ = drain_insert_history(&mut rx); chat.replay_thread_item( - AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: vec!["Summary only".to_string()], content: vec!["Raw reasoning".to_string()], @@ -1337,7 +1340,7 @@ async fn replayed_mcp_tool_call_stays_active_until_completion() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let _ = drain_insert_history(&mut rx); - let mut item = AppServerThreadItem::McpToolCall { + let mut item = AppServerThreadItemPayload::McpToolCall { id: "mcp-1".to_string(), server: "copilot-bridge".to_string(), tool: "copilot".to_string(), @@ -1363,7 +1366,7 @@ async fn replayed_mcp_tool_call_stays_active_until_completion() { assert!(active.contains("Calling")); assert!(!active.contains("MCP tool call completed without a result")); - if let AppServerThreadItem::McpToolCall { status, .. } = &mut item { + if let AppServerThreadItemPayload::McpToolCall { status, .. } = &mut item { *status = codex_app_server_protocol::McpToolCallStatus::Completed; } chat.on_mcp_tool_call_completed(item); @@ -1382,11 +1385,7 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() { let _ = drain_insert_history(&mut rx); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::McpToolCall { + ServerNotification::ItemCompleted(ItemCompletedNotification::new("thread-1".to_string(), "turn-1".to_string(), AppServerThreadItemPayload::McpToolCall { id: "mcp-failed".to_string(), server: server.to_string(), tool: "js".to_string(), @@ -1408,8 +1407,7 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() { })), error: None, duration_ms: Some(5), - }, - }), + }, /*started_at_ms*/ None, /*completed_at_ms*/ 0)), /*replay_kind*/ None, ); @@ -1453,7 +1451,7 @@ async fn deferred_mcp_lifecycle_events_keep_fifo_after_stream_finishes() { chat.history_render_mode(), )); - chat.on_mcp_tool_call_started(AppServerThreadItem::McpToolCall { + chat.on_mcp_tool_call_started(AppServerThreadItemPayload::McpToolCall { id: "mcp-deferred".to_string(), server: "copilot-bridge".to_string(), tool: "copilot".to_string(), @@ -1471,7 +1469,7 @@ async fn deferred_mcp_lifecycle_events_keep_fifo_after_stream_finishes() { assert!(!chat.interrupts.is_empty()); chat.stream_controller = None; - chat.on_mcp_tool_call_completed(AppServerThreadItem::McpToolCall { + chat.on_mcp_tool_call_completed(AppServerThreadItemPayload::McpToolCall { id: "mcp-deferred".to_string(), server: "copilot-bridge".to_string(), tool: "copilot".to_string(), @@ -1543,16 +1541,17 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { ); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::Reasoning { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: vec!["Summary only".to_string()], content: Vec::new(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -1619,11 +1618,10 @@ async fn live_reasoning_summary_drops_empty_parts_without_losing_content() { } chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::Reasoning { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::Reasoning { id: "reasoning-1".to_string(), summary: vec![ "**Plan**\n\ndone".to_string(), @@ -1631,7 +1629,9 @@ async fn live_reasoning_summary_drops_empty_parts_without_losing_content() { ], content: Vec::new(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 2b3257073cda..2b325ba2dbf5 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1,4 +1,5 @@ use super::*; +use codex_app_server_protocol::ThreadItem; use pretty_assertions::assert_eq; fn paste_hidden_plan_shell_payload(chat: &mut ChatWidget) -> String { @@ -812,14 +813,16 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![AppServerThreadItem::AgentMessage { - id: "msg-plan".to_string(), - text: "Plan details".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: None, - delivery: None, - questions: None, - }], + items: vec![ThreadItem::untimed( + AppServerThreadItemPayload::AgentMessage { + id: "msg-plan".to_string(), + text: "Plan details".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + delivery: None, + questions: None, + }, + )], status: AppServerTurnStatus::Completed, error: None, started_at: None, @@ -853,14 +856,16 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com id: "turn-1".to_string(), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![AppServerThreadItem::AgentMessage { - id: "msg-plan-replay".to_string(), - text: "Plan details".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: None, - delivery: None, - questions: None, - }], + items: vec![ThreadItem::untimed( + AppServerThreadItemPayload::AgentMessage { + id: "msg-plan-replay".to_string(), + text: "Plan details".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + delivery: None, + questions: None, + }, + )], status: AppServerTurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/tui/src/chatwidget/tests/question_notifications_tests.rs b/codex-rs/tui/src/chatwidget/tests/question_notifications_tests.rs index 7f88c3686327..8907c40f2f5e 100644 --- a/codex-rs/tui/src/chatwidget/tests/question_notifications_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/question_notifications_tests.rs @@ -7,7 +7,7 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn live_async_question_notifies_once_and_takes_priority_over_turn_completion() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let item = AppServerThreadItem::AgentMessage { + let item = AppServerThreadItemPayload::AgentMessage { id: "question".into(), text: String::new(), phase: None, @@ -26,12 +26,13 @@ async fn live_async_question_notifies_once_and_takes_priority_over_turn_completi assert!(chat.pending_notification.is_none()); } - let notification = ServerNotification::ItemCompleted(ItemCompletedNotification { + let notification = ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread".into(), + "turn".into(), item, - thread_id: "thread".into(), - turn_id: "turn".into(), - completed_at_ms: 0, - }); + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )); chat.handle_server_notification(notification.clone(), /*replay_kind*/ None); chat.notify(Notification::AgentTurnComplete { response: "Done".into(), diff --git a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs index 1bd8d1c25fac..6357208c95c2 100644 --- a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs @@ -779,7 +779,7 @@ async fn question_drafts_survive_navigation_and_snapshot_replay() { chat.bottom_pane.handle_paste("second draft".into()); let saved = chat.capture_thread_input_state(); let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; - let item = AppServerThreadItem::AgentMessage { + let item = AppServerThreadItemPayload::AgentMessage { id: "buffered".into(), text: String::new(), phase: None, @@ -795,12 +795,13 @@ async fn question_drafts_survive_navigation_and_snapshot_replay() { assert!(chat.bottom_pane.questions.is_none()); } chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread".into(), + "turn".into(), item, - thread_id: "thread".into(), - turn_id: "turn".into(), - completed_at_ms: 0, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), Some(ReplayKind::ThreadSnapshot), ); assert_eq!(question_count(&chat), 1); @@ -980,7 +981,7 @@ async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_othe let reply = format!( "# Context from my IDE setup:\n\n## Open tabs:\n- lib.rs: src/lib.rs\n\n## My request for Codex:\n{reply}" ); - let item = AppServerThreadItem::UserMessage { + let item = AppServerThreadItemPayload::UserMessage { id: "answer".into(), client_id: None, content: vec![UserInput::Text { @@ -989,12 +990,13 @@ async fn desktop_async_answer_dismisses_only_its_question_and_preserves_the_othe }], }; chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread".into(), - turn_id: "turn".into(), - completed_at_ms: 0, - item: item.clone(), - }), + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread".into(), + "turn".into(), + item.clone(), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), replay_kind, ); assert_eq!(question_count(&chat), 1); diff --git a/codex-rs/tui/src/chatwidget/tests/reasoning_status_tests.rs b/codex-rs/tui/src/chatwidget/tests/reasoning_status_tests.rs index 904cb258279e..67bdacdca4e2 100644 --- a/codex-rs/tui/src/chatwidget/tests/reasoning_status_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/reasoning_status_tests.rs @@ -18,16 +18,17 @@ fn delta(chat: &mut ChatWidget, id: &str, text: &str) { fn complete(chat: &mut ChatWidget, id: &str) { chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::Reasoning { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::Reasoning { id: id.to_string(), summary: Vec::new(), content: Vec::new(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); } @@ -216,7 +217,7 @@ async fn reasoning_status_replay_retains_last_usable_heading() { ("third", Vec::new(), "Second heading"), ] { chat.handle_thread_item( - AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: id.to_string(), summary, content: Vec::new(), @@ -272,7 +273,7 @@ async fn completed_reasoning_stays_in_expanded_transcript_for_live_and_replay() ] { if replay { chat.handle_thread_item( - AppServerThreadItem::Reasoning { + AppServerThreadItemPayload::Reasoning { id: id.to_string(), summary: vec![summary.to_string()], content: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index b72283bee26d..640019ba6ced 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -149,17 +149,17 @@ async fn live_review_prompt_item_is_not_rendered() { async fn live_app_server_review_prompt_item_is_not_rendered() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let review_mode_item = AppServerThreadItem::EnteredReviewMode { + let review_mode_item = AppServerThreadItemPayload::EnteredReviewMode { id: "review-start".to_string(), review: "changes against 'main'".to_string(), }; chat.handle_server_notification( - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: review_mode_item.clone(), - }), + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + review_mode_item.clone(), + /*started_at_ms*/ 0, + )), /*replay_kind*/ None, ); let cells = drain_insert_history(&mut rx); @@ -167,22 +167,22 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { assert!(lines_to_single_string(&cells[0]).contains("Code review started")); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: review_mode_item, - }), + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + review_mode_item, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); assert!(drain_insert_history(&mut rx).is_empty()); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::UserMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + AppServerThreadItemPayload::UserMessage { id: "review-prompt".to_string(), client_id: None, content: vec![AppServerUserInput::Text { @@ -190,7 +190,9 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { text_elements: Vec::new(), }], }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); @@ -467,7 +469,7 @@ async fn identical_steer_receipts_only_acknowledge_the_matching_submission() { (pending.client_id.as_str(), VecDeque::new()), ] { chat.handle_thread_item( - AppServerThreadItem::UserMessage { + AppServerThreadItemPayload::UserMessage { id: client_id.to_string(), client_id: Some(client_id.to_string()), content: vec![UserInput::Text { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 900ad18062a7..41ff4c3fbd2b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1626,11 +1626,10 @@ async fn slash_copy_picker_uses_completed_commentary_during_active_turn() { let commentary = "Here is the command:\n\n```sh\necho current\n```"; let current_thread_id = chat.thread_id.map(|id| id.to_string()).unwrap_or_default(); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: current_thread_id, - turn_id: "active".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::AgentMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + current_thread_id, + "active".to_string(), + AppServerThreadItemPayload::AgentMessage { id: "active-commentary".to_string(), text: commentary.to_string(), phase: Some(MessagePhase::Commentary), @@ -1638,7 +1637,9 @@ async fn slash_copy_picker_uses_completed_commentary_during_active_turn() { delivery: None, questions: None, }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); while rx.try_recv().is_ok() {} @@ -1660,15 +1661,16 @@ async fn slash_copy_state_tracks_plan_item_completion() { let plan_text = "## Plan\n\n1. Build it\n2. Test it\n\n```sh\r\njust test \r\n```".to_string(); chat.handle_server_notification( - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: String::new(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, - item: AppServerThreadItem::Plan { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + String::new(), + "turn-1".to_string(), + AppServerThreadItemPayload::Plan { id: "plan-1".to_string(), text: plan_text.clone(), }, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), /*replay_kind*/ None, ); handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index e9e1ab1bcd52..00dab60b7dfe 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -5769,7 +5769,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { let cwd = chat.config.cwd.clone(); handle_exec_begin( &mut chat, - AppServerThreadItem::CommandExecution { + AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "c1".into(), @@ -5788,7 +5788,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { ); handle_exec_end( &mut chat, - AppServerThreadItem::CommandExecution { + AppServerThreadItemPayload::CommandExecution { model_context: None, sandbox_type: None, id: "c1".into(), diff --git a/codex-rs/tui/src/chatwidget/tests/subagent_activity_tests.rs b/codex-rs/tui/src/chatwidget/tests/subagent_activity_tests.rs index 9f04098d5ebf..5079ecbba6f1 100644 --- a/codex-rs/tui/src/chatwidget/tests/subagent_activity_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/subagent_activity_tests.rs @@ -14,7 +14,7 @@ enum FinalizedEvent { } fn complete_subagent(chat: &mut ChatWidget, id: &str) { - let item = AppServerThreadItem::SubAgentActivity { + let item = AppServerThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: id.to_string(), @@ -23,18 +23,19 @@ fn complete_subagent(chat: &mut ChatWidget, id: &str) { agent_path: "/root/stack_tip".to_string(), }; for notification in [ - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - started_at_ms: 0, - item: item.clone(), - }), - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - completed_at_ms: 0, + ServerNotification::ItemStarted(ItemStartedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), + item.clone(), + /*started_at_ms*/ 0, + )), + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + "turn-1".to_string(), item, - }), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )), ] { chat.handle_server_notification(notification, /*replay_kind*/ None); } diff --git a/codex-rs/tui/src/chatwidget/tests/tool_activity_tests.rs b/codex-rs/tui/src/chatwidget/tests/tool_activity_tests.rs index 4638d46b8c0d..572a10565b5e 100644 --- a/codex-rs/tui/src/chatwidget/tests/tool_activity_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/tool_activity_tests.rs @@ -2,7 +2,7 @@ use super::*; #[tokio::test] async fn image_result_live_and_replay_render_in_the_owning_call() { - let item: AppServerThreadItem = serde_json::from_value(json!({ + let item: AppServerThreadItemPayload = serde_json::from_value(json!({ "type": "mcpToolCall", "id": "image", "server": "node_repl", "tool": "js", "status": "completed", "arguments": {"title": "Inspect screenshot", "code": "showImage()"}, "result": {"content": [ @@ -18,7 +18,7 @@ async fn image_result_live_and_replay_render_in_the_owning_call() { chat.replay_thread_item(item.clone(), "turn-1".into(), ReplayKind::ThreadSnapshot); } else { let mut started = item.clone(); - if let AppServerThreadItem::McpToolCall { status, result, .. } = &mut started { + if let AppServerThreadItemPayload::McpToolCall { status, result, .. } = &mut started { *status = codex_app_server_protocol::McpToolCallStatus::InProgress; *result = None; } diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index 60617254f6de..9f13cc572bec 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -42,7 +42,7 @@ impl ChatWidget { self.request_redraw(); } - pub(super) fn on_file_change_completed(&mut self, item: ThreadItem) { + pub(super) fn on_file_change_completed(&mut self, item: ThreadItemPayload) { self.defer_or_handle( item, InterruptManager::push_item_completed, @@ -50,7 +50,7 @@ impl ChatWidget { ); } - pub(super) fn on_mcp_tool_call_started(&mut self, item: ThreadItem) { + pub(super) fn on_mcp_tool_call_started(&mut self, item: ThreadItemPayload) { self.defer_or_handle( item, InterruptManager::push_item_started, @@ -58,7 +58,7 @@ impl ChatWidget { ); } - pub(super) fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) { + pub(super) fn on_mcp_tool_call_completed(&mut self, item: ThreadItemPayload) { self.defer_or_handle( item, InterruptManager::push_item_completed, @@ -111,8 +111,8 @@ impl ChatWidget { self.request_redraw(); } - pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { - let ThreadItem::CollabAgentToolCall { + pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItemPayload) { + let ThreadItemPayload::CollabAgentToolCall { id, tool, status, .. } = &item else { @@ -142,7 +142,7 @@ impl ChatWidget { } } - pub(super) fn on_sub_agent_activity(&mut self, item: ThreadItem) { + pub(super) fn on_sub_agent_activity(&mut self, item: ThreadItemPayload) { // Background agents can finish while the parent answer is still streaming. // Keep that stream intact until its authoritative message completion. // After the turn stops, leftover prompts must not hold up late activity. @@ -157,14 +157,14 @@ impl ChatWidget { } } - fn handle_sub_agent_activity_now(&mut self, item: ThreadItem) { + fn handle_sub_agent_activity_now(&mut self, item: ThreadItemPayload) { if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) { self.on_collab_event(cell); } } - pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { - let ThreadItem::FileChange { status, .. } = item else { + pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItemPayload) { + let ThreadItemPayload::FileChange { status, .. } = item else { return; }; // If the patch was successful, just let the "Edited" block stand. @@ -174,8 +174,8 @@ impl ChatWidget { } } - pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { - let ThreadItem::McpToolCall { + pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItemPayload) { + let ThreadItemPayload::McpToolCall { id, server, tool, @@ -212,7 +212,7 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) { + pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItemPayload) { self.flush_answer_stream_with_separator(); let Some(McpHistory { @@ -294,28 +294,38 @@ impl ChatWidget { } } - pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { + pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItemPayload) { match item { - item @ ThreadItem::CommandExecution { .. } => { + item @ ThreadItemPayload::CommandExecution { .. } => { self.handle_command_execution_started_now(item); } - item @ ThreadItem::McpToolCall { .. } => { + item @ ThreadItemPayload::McpToolCall { .. } => { self.handle_mcp_tool_call_started_now(item); } - item @ ThreadItem::DynamicToolCall { .. } => self.handle_dynamic_tool_item_now(item), + item @ ThreadItemPayload::DynamicToolCall { .. } => { + self.handle_dynamic_tool_item_now(item) + } _ => {} } } - pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItem) { + pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItemPayload) { match item { - item @ ThreadItem::CommandExecution { .. } => { + item @ ThreadItemPayload::CommandExecution { .. } => { self.handle_command_execution_completed_now(item); } - item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item), - item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item), - item @ ThreadItem::DynamicToolCall { .. } => self.handle_dynamic_tool_item_now(item), - item @ ThreadItem::SubAgentActivity { .. } => self.handle_sub_agent_activity_now(item), + item @ ThreadItemPayload::FileChange { .. } => { + self.handle_file_change_completed_now(item) + } + item @ ThreadItemPayload::McpToolCall { .. } => { + self.handle_mcp_tool_call_completed_now(item) + } + item @ ThreadItemPayload::DynamicToolCall { .. } => { + self.handle_dynamic_tool_item_now(item) + } + item @ ThreadItemPayload::SubAgentActivity { .. } => { + self.handle_sub_agent_activity_now(item) + } _ => {} } } diff --git a/codex-rs/tui/src/clipboard_copy/worker_tests.rs b/codex-rs/tui/src/clipboard_copy/worker_tests.rs index ed48e126d0b9..2088cfed4df9 100644 --- a/codex-rs/tui/src/clipboard_copy/worker_tests.rs +++ b/codex-rs/tui/src/clipboard_copy/worker_tests.rs @@ -37,11 +37,10 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion() crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; chat.handle_server_notification( codex_app_server_protocol::ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - thread_id: String::new(), - turn_id: "turn-1".into(), - completed_at_ms: 0, - item: codex_app_server_protocol::ThreadItem::AgentMessage { + codex_app_server_protocol::ItemCompletedNotification::new( + String::new(), + "turn-1".into(), + codex_app_server_protocol::ThreadItemPayload::AgentMessage { id: "msg-1".into(), text: "café\nsecond line".into(), phase: None, @@ -49,7 +48,9 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion() delivery: None, questions: None, }, - }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), ), /*replay_kind*/ None, ); diff --git a/codex-rs/tui/src/dynamic_tools.rs b/codex-rs/tui/src/dynamic_tools.rs index 4b895da67b3f..184bc64ec158 100644 --- a/codex-rs/tui/src/dynamic_tools.rs +++ b/codex-rs/tui/src/dynamic_tools.rs @@ -29,8 +29,8 @@ use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadItemEntry; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::ThreadListParams; @@ -912,9 +912,9 @@ async fn execute_inner( .cloned() .map(|item| ThreadItemEntry { turn_id: turn.id.clone(), + started_at_ms: item.started_at_ms, + completed_at_ms: item.completed_at_ms, item, - started_at_ms: None, - completed_at_ms: None, }) .collect(), ) @@ -967,8 +967,8 @@ async fn execute_inner( }; } let latest_assistant_message = latest_turn.as_ref().and_then(|turn| { - turn.items.iter().rev().find_map(|item| match item { - ThreadItem::AgentMessage { + turn.item_payloads().rev().find_map(|item| match item { + ThreadItemPayload::AgentMessage { id, text, phase, .. } => Some(json!({ "id": id, @@ -983,58 +983,58 @@ async fn execute_inner( .as_ref() .zip(latest_items.as_ref()) .and_then(|(turn, items)| { - items.iter().find_map(|entry| match &entry.item { - ThreadItem::CommandExecution { id, status, .. } => { + items.iter().find_map(|entry| match &entry.item.payload { + ThreadItemPayload::CommandExecution { id, status, .. } => { Some(json!({ "id": id, "turnId": turn.id, "type": "commandExecution", "name": "commandExecution", "status": status })) } - ThreadItem::FileChange { id, status, .. } => Some(json!({ + ThreadItemPayload::FileChange { id, status, .. } => Some(json!({ "id": id, "turnId": turn.id, "type": "fileChange", "name": "fileChange", "status": status })), - ThreadItem::ImageGeneration(item) => Some(json!({ + ThreadItemPayload::ImageGeneration(item) => Some(json!({ "id": item.id, "turnId": turn.id, "type": "imageGeneration", "name": "imageGeneration", "status": item.status })), - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { id, tool, status, .. } => Some(json!({ "id": id, "turnId": turn.id, "type": "mcpToolCall", "name": tool, "status": status })), - ThreadItem::DynamicToolCall { + ThreadItemPayload::DynamicToolCall { id, tool, status, .. } => Some(json!({ "id": id, "turnId": turn.id, "type": "dynamicToolCall", "name": tool, "status": status })), - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { id, tool, status, .. } => Some(json!({ "id": id, "turnId": turn.id, "type": "collabAgentToolCall", "name": tool, "status": status })), - ThreadItem::Sleep(item) => Some(json!({ + ThreadItemPayload::Sleep(item) => Some(json!({ "id": item.id, "turnId": turn.id, "type": "sleep", "name": "sleep", "status": null })), - ThreadItem::WebSearch(item) => Some(json!({ + ThreadItemPayload::WebSearch(item) => Some(json!({ "id": item.id, "turnId": turn.id, "type": "webSearch", "name": "webSearch", "status": null })), - ThreadItem::UserMessage { .. } - | ThreadItem::FunctionCallOutput { .. } - | ThreadItem::HookPrompt { .. } - | ThreadItem::AgentMessage { .. } - | ThreadItem::Plan { .. } - | ThreadItem::Reasoning { .. } - | ThreadItem::SubAgentActivity { .. } - | ThreadItem::ImageView { .. } - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } - | ThreadItem::ContextCompaction { .. } => None, + ThreadItemPayload::UserMessage { .. } + | ThreadItemPayload::FunctionCallOutput { .. } + | ThreadItemPayload::HookPrompt { .. } + | ThreadItemPayload::AgentMessage { .. } + | ThreadItemPayload::Plan { .. } + | ThreadItemPayload::Reasoning { .. } + | ThreadItemPayload::SubAgentActivity { .. } + | ThreadItemPayload::ImageView { .. } + | ThreadItemPayload::EnteredReviewMode { .. } + | ThreadItemPayload::ExitedReviewMode { .. } + | ThreadItemPayload::ContextCompaction { .. } => None, }) }); polls.push(json!({ @@ -1358,11 +1358,10 @@ fn thread_summary(thread: &Thread) -> Value { fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Value { let mut items: Vec = turn - .items - .iter() + .item_payloads() .rev() .map(|item| match item { - ThreadItem::UserMessage { id, content, .. } => json!({ + ThreadItemPayload::UserMessage { id, content, .. } => json!({ "type": "userMessage", "id": id, "content": content.iter().map(|input| match input { @@ -1392,10 +1391,10 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu UserInput::Mention { name, path } => json!({"type": "mention", "name": name, "path": path}), }).collect::>() }), - ThreadItem::HookPrompt { id, fragments } => json!({ + ThreadItemPayload::HookPrompt { id, fragments } => json!({ "type": "hookPrompt", "id": id, "fragmentCount": fragments.len() }), - ThreadItem::FunctionCallOutput { + ThreadItemPayload::FunctionCallOutput { id, name, namespace, @@ -1420,13 +1419,13 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu } item } - ThreadItem::AgentMessage { id, text, phase, .. } => json!({ + ThreadItemPayload::AgentMessage { id, text, phase, .. } => json!({ "type": "agentMessage", "id": id, "text": text, "phase": phase }), - ThreadItem::Plan { id, text } => json!({ + ThreadItemPayload::Plan { id, text } => json!({ "type": "plan", "id": id, "text": truncate(text, DEFAULT_OUTPUT_CHARS) }), - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id, summary, content, @@ -1443,7 +1442,7 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu } item } - ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { id, command, cwd, @@ -1467,7 +1466,7 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu } item } - ThreadItem::FileChange { + ThreadItemPayload::FileChange { id, changes, status, @@ -1483,7 +1482,7 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu item }).collect::>() }), - ThreadItem::McpToolCall { + ThreadItemPayload::McpToolCall { id, server, tool, @@ -1495,7 +1494,7 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu "type": "mcpToolCall", "id": id, "server": server, "tool": tool, "arguments": arguments, "status": status, "durationMs": duration_ms }), - ThreadItem::DynamicToolCall { + ThreadItemPayload::DynamicToolCall { id, namespace, tool, @@ -1509,7 +1508,7 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu "tool": tool, "arguments": arguments, "status": status, "success": success, "durationMs": duration_ms }), - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { id, tool, status, @@ -1525,7 +1524,7 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu "receiverThreadIds": receiver_thread_ids, "prompt": prompt, "model": model, "reasoningEffort": reasoning_effort }), - ThreadItem::SubAgentActivity { + ThreadItemPayload::SubAgentActivity { id, kind, agent_thread_id, @@ -1535,17 +1534,17 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu "type": "subAgentActivity", "id": id, "kind": kind, "agentThreadId": agent_thread_id, "agentPath": agent_path }), - ThreadItem::WebSearch(item) => json!({ + ThreadItemPayload::WebSearch(item) => json!({ "type": "webSearch", "id": item.id, "query": truncate(&item.query, DEFAULT_OUTPUT_CHARS), "action": item.action }), - ThreadItem::ImageView { id, path } => json!({ + ThreadItemPayload::ImageView { id, path } => json!({ "type": "imageView", "id": id, "path": path }), - ThreadItem::Sleep(item) => json!({ + ThreadItemPayload::Sleep(item) => json!({ "type": "sleep", "id": item.id, "durationMs": item.duration_ms }), - ThreadItem::ImageGeneration(item) => { + ThreadItemPayload::ImageGeneration(item) => { let mut image = json!({ "type": "imageGeneration", "id": item.id, "status": item.status, "revisedPrompt": item.revised_prompt.as_deref().map(|prompt| truncate(prompt, DEFAULT_OUTPUT_CHARS)), @@ -1563,13 +1562,13 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu } image } - ThreadItem::EnteredReviewMode { id, review } => json!({ + ThreadItemPayload::EnteredReviewMode { id, review } => json!({ "type": "enteredReviewMode", "id": id, "review": truncate(review, DEFAULT_OUTPUT_CHARS) }), - ThreadItem::ExitedReviewMode { id, review } => json!({ + ThreadItemPayload::ExitedReviewMode { id, review } => json!({ "type": "exitedReviewMode", "id": id, "review": truncate(review, DEFAULT_OUTPUT_CHARS) }), - ThreadItem::ContextCompaction { id } => json!({ + ThreadItemPayload::ContextCompaction { id } => json!({ "type": "contextCompaction", "id": id }), }) diff --git a/codex-rs/tui/src/history_cell/dynamic.rs b/codex-rs/tui/src/history_cell/dynamic.rs index 9c5c603064d8..1ac5eb7c378b 100644 --- a/codex-rs/tui/src/history_cell/dynamic.rs +++ b/codex-rs/tui/src/history_cell/dynamic.rs @@ -12,7 +12,7 @@ use crate::terminal_hyperlinks::visible_lines; use crate::wrapping::RtOptions; use codex_app_server_protocol::DynamicToolCallOutputContentItem; use codex_app_server_protocol::DynamicToolCallStatus; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use ratatui::style::Stylize; use ratatui::text::Line; use serde_json::Value; @@ -36,7 +36,7 @@ struct DynamicToolCallData { } impl DynamicToolCallCell { - pub(crate) fn from_item(item: ThreadItem) -> Option { + pub(crate) fn from_item(item: ThreadItemPayload) -> Option { let (call_id, data) = DynamicToolCallData::from_item(item)?; Some(Self { call_id, @@ -56,7 +56,7 @@ impl DynamicToolCallCell { } /// Updates the original retained row when concurrent calls complete in a different order. - pub(crate) fn update_from_item(&self, item: ThreadItem) -> bool { + pub(crate) fn update_from_item(&self, item: ThreadItemPayload) -> bool { let Some((call_id, data)) = DynamicToolCallData::from_item(item) else { return false; }; @@ -87,8 +87,8 @@ impl DynamicToolCallCell { } impl DynamicToolCallData { - fn from_item(item: ThreadItem) -> Option<(String, Self)> { - let ThreadItem::DynamicToolCall { + fn from_item(item: ThreadItemPayload) -> Option<(String, Self)> { + let ThreadItemPayload::DynamicToolCall { id, namespace, tool, diff --git a/codex-rs/tui/src/history_cell/dynamic_tests.rs b/codex-rs/tui/src/history_cell/dynamic_tests.rs index 18bc6f0b3d9a..e01278849ca0 100644 --- a/codex-rs/tui/src/history_cell/dynamic_tests.rs +++ b/codex-rs/tui/src/history_cell/dynamic_tests.rs @@ -8,8 +8,8 @@ use crate::thread_transcript::thread_items_to_transcript_cells; use pretty_assertions::assert_eq; use serde_json::json; -fn item(status: DynamicToolCallStatus, output: Option<&str>) -> ThreadItem { - ThreadItem::DynamicToolCall { +fn item(status: DynamicToolCallStatus, output: Option<&str>) -> ThreadItemPayload { + ThreadItemPayload::DynamicToolCall { id: "dynamic-1".to_string(), namespace: Some("example".to_string()), tool: "inspect".to_string(), diff --git a/codex-rs/tui/src/lib_tests.rs b/codex-rs/tui/src/lib_tests.rs index d911e9302f3a..e7eed00bbc90 100644 --- a/codex-rs/tui/src/lib_tests.rs +++ b/codex-rs/tui/src/lib_tests.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::Turn; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -1489,11 +1490,11 @@ async fn resume_picker_loads_complete_paginated_and_legacy_transcripts() -> colo started .turns .iter() - .flat_map(|turn| &turn.items) + .flat_map(Turn::item_payloads) .any(|item| { matches!( item, - codex_app_server_protocol::ThreadItem::UserMessage { .. } + codex_app_server_protocol::ThreadItemPayload::UserMessage { .. } ) }) ); diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 821027058027..e7f3d38951dc 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -13,7 +13,7 @@ use codex_app_server_protocol::CollabAgentStatus; use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::SubAgentActivityKind; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use crossterm::event::KeyCode; @@ -186,9 +186,9 @@ fn next_agent_word_motion_fallback( false } -pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option { +pub(crate) fn spawn_request_summary(item: &ThreadItemPayload) -> Option { match item { - ThreadItem::CollabAgentToolCall { + ThreadItemPayload::CollabAgentToolCall { tool: CollabAgentTool::SpawnAgent, model: Some(model), reasoning_effort: Some(reasoning_effort), @@ -202,11 +202,11 @@ pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option, mut agent_metadata: impl FnMut(ThreadId) -> AgentMetadata, ) -> Option { - let ThreadItem::CollabAgentToolCall { + let ThreadItemPayload::CollabAgentToolCall { tool, status, receiver_thread_ids, @@ -284,8 +284,10 @@ pub(crate) fn tool_call_history_cell( } } -pub(crate) fn sub_agent_activity_display(item: &ThreadItem) -> Option { - let ThreadItem::SubAgentActivity { +pub(crate) fn sub_agent_activity_display( + item: &ThreadItemPayload, +) -> Option { + let ThreadItemPayload::SubAgentActivity { kind, agent_thread_id, agent_path, @@ -306,8 +308,10 @@ pub(crate) fn sub_agent_activity_display(item: &ThreadItem) -> Option Option { - let ThreadItem::SubAgentActivity { +pub(crate) fn sub_agent_activity_history_cell( + item: &ThreadItemPayload, +) -> Option { + let ThreadItemPayload::SubAgentActivity { kind, agent_path, .. } = item else { @@ -680,7 +684,7 @@ mod tests { #[test] fn interacted_sub_agent_activity_does_not_change_liveness() { - let item = ThreadItem::SubAgentActivity { + let item = ThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "activity-1".to_string(), @@ -695,7 +699,7 @@ mod tests { #[test] fn completed_sub_agent_activity_stops_running_liveness() { let thread_id = ThreadId::new(); - let item = ThreadItem::SubAgentActivity { + let item = ThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "activity-1".to_string(), @@ -724,7 +728,7 @@ mod tests { .expect("valid bob thread id"); let spawn = tool_call_history_cell( - &ThreadItem::CollabAgentToolCall { + &ThreadItemPayload::CollabAgentToolCall { id: "call-spawn".to_string(), tool: CollabAgentTool::SpawnAgent, status: CollabAgentToolCallStatus::Completed, @@ -744,7 +748,7 @@ mod tests { .expect("spawn item renders"); let send = tool_call_history_cell( - &ThreadItem::CollabAgentToolCall { + &ThreadItemPayload::CollabAgentToolCall { id: "call-send".to_string(), tool: CollabAgentTool::SendInput, status: CollabAgentToolCallStatus::Completed, @@ -764,7 +768,7 @@ mod tests { .expect("send-input item renders"); let waiting = tool_call_history_cell( - &ThreadItem::CollabAgentToolCall { + &ThreadItemPayload::CollabAgentToolCall { id: "call-wait".to_string(), tool: CollabAgentTool::Wait, status: CollabAgentToolCallStatus::InProgress, @@ -781,7 +785,7 @@ mod tests { .expect("wait begin item renders"); let finished = tool_call_history_cell( - &ThreadItem::CollabAgentToolCall { + &ThreadItemPayload::CollabAgentToolCall { id: "call-wait".to_string(), tool: CollabAgentTool::Wait, status: CollabAgentToolCallStatus::Completed, @@ -807,7 +811,7 @@ mod tests { .expect("wait end item renders"); let close = tool_call_history_cell( - &ThreadItem::CollabAgentToolCall { + &ThreadItemPayload::CollabAgentToolCall { id: "call-close".to_string(), tool: CollabAgentTool::CloseAgent, status: CollabAgentToolCallStatus::Completed, @@ -891,7 +895,7 @@ mod tests { let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") .expect("valid robie thread id"); let cell = tool_call_history_cell( - &ThreadItem::CollabAgentToolCall { + &ThreadItemPayload::CollabAgentToolCall { id: "call-spawn".to_string(), tool: CollabAgentTool::SpawnAgent, status: CollabAgentToolCallStatus::Completed, @@ -930,7 +934,7 @@ mod tests { .expect("valid robie thread id"); let cell = tool_call_history_cell( - &ThreadItem::CollabAgentToolCall { + &ThreadItemPayload::CollabAgentToolCall { id: "call-resume".to_string(), tool: CollabAgentTool::ResumeAgent, status: CollabAgentToolCallStatus::Completed, diff --git a/codex-rs/tui/src/pager_overlay/transcript_tests.rs b/codex-rs/tui/src/pager_overlay/transcript_tests.rs index d0f9fc109f74..4dd388f9cd66 100644 --- a/codex-rs/tui/src/pager_overlay/transcript_tests.rs +++ b/codex-rs/tui/src/pager_overlay/transcript_tests.rs @@ -272,7 +272,7 @@ async fn focus_loss_stops_edge_drag_and_preserves_selection_after_focus_returns( #[tokio::test] async fn opening_find_retains_the_selected_mutable_revision() -> Result<()> { let cell = history_cell::DynamicToolCallCell::from_item( - codex_app_server_protocol::ThreadItem::DynamicToolCall { + codex_app_server_protocol::ThreadItemPayload::DynamicToolCall { id: "tool".into(), namespace: None, tool: "inspect".into(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index dfac57693612..e3f1072d1d41 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -3704,6 +3704,7 @@ mod tests { use super::*; use chrono::Duration; use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSourceKind; use codex_config::CONFIG_TOML_FILE; use codex_protocol::ThreadId; @@ -7011,26 +7012,26 @@ session_picker_view = "dense" root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ - ThreadItem::UserMessage { + ThreadItem::untimed(ThreadItemPayload::UserMessage { id: String::from("user-1"), client_id: None, content: vec![codex_app_server_protocol::UserInput::Text { text: String::from("hello from user"), text_elements: Vec::new(), }], - }, - ThreadItem::AgentMessage { + }), + ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: String::from("agent-1"), text: String::from("hello from assistant"), phase: None, memory_citation: None, delivery: None, questions: None, - }, - ThreadItem::Plan { + }), + ThreadItem::untimed(ThreadItemPayload::Plan { id: String::from("plan-1"), text: String::from("1. Do the thing"), - }, + }), ], status: codex_app_server_protocol::TurnStatus::Completed, error: None, @@ -7098,11 +7099,11 @@ session_picker_view = "dense" id: String::from("turn-1"), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::Reasoning { + items: vec![ThreadItem::untimed(ThreadItemPayload::Reasoning { id: String::from("reasoning-1"), summary: Vec::new(), content: vec![String::from("private raw chain of thought")], - }], + })], status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, @@ -7177,11 +7178,11 @@ session_picker_view = "dense" id: String::from("turn-1"), root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, - items: vec![ThreadItem::Reasoning { + items: vec![ThreadItem::untimed(ThreadItemPayload::Reasoning { id: String::from("reasoning-1"), summary: vec![String::from("public summary")], content: vec![String::from("raw reasoning content")], - }], + })], status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/tui/src/resume_picker_transcript_preview.rs b/codex-rs/tui/src/resume_picker_transcript_preview.rs index fb642f536291..4f5ed57446ba 100644 --- a/codex-rs/tui/src/resume_picker_transcript_preview.rs +++ b/codex-rs/tui/src/resume_picker_transcript_preview.rs @@ -17,7 +17,7 @@ use crate::git_action_directives::parse_assistant_markdown; use crate::inline_visualization::InlineVisualizationContext; use crate::legacy_core::config::Config; use codex_app_server_protocol::ThreadHistoryMode; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_rollout::ReverseJsonlScanner; @@ -87,7 +87,7 @@ pub(crate) async fn load_transcript_preview( .turns .iter() .rev() - .flat_map(|turn| turn.items.iter().rev()), + .flat_map(|turn| turn.item_payloads().rev()), thread.cwd.as_path(), inline_visualization_context.as_ref(), ); @@ -118,7 +118,7 @@ pub(crate) async fn load_transcript_preview( page.data .iter() .take(remaining_items) - .map(|entry| &entry.item), + .map(|entry| &entry.item.payload), cwd, inline_visualization_context.as_ref(), ); @@ -228,13 +228,13 @@ impl Seek for BoundedRolloutTailReader { /// Appends the newest preview lines from items already ordered newest-first. fn append_transcript_preview_lines<'a>( lines: &mut Vec, - items: impl Iterator, + items: impl Iterator, cwd: &Path, inline_visualization_context: Option<&InlineVisualizationContext>, ) { for item in items { match item { - ThreadItem::UserMessage { content, .. } => { + ThreadItemPayload::UserMessage { content, .. } => { let text = content .iter() .filter_map(|input| match input { @@ -253,7 +253,7 @@ fn append_transcript_preview_lines<'a>( inline_visualization_context, ); } - ThreadItem::AgentMessage { text, .. } => { + ThreadItemPayload::AgentMessage { text, .. } => { append_transcript_preview_text( lines, TranscriptPreviewSpeaker::Assistant, diff --git a/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs b/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs index cfbb9ee5e7b1..fe516c6513cd 100644 --- a/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs +++ b/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs @@ -272,7 +272,7 @@ fn legacy_transcript_preview_falls_back_when_scan_budget_is_exhausted() { #[test] fn transcript_preview_reverse_scan_stops_before_older_items() { - let newest = ThreadItem::AgentMessage { + let newest = ThreadItemPayload::AgentMessage { id: String::from("final"), text: String::from("one\ntwo\nthree\nfour\nfive\nsix"), phase: None, @@ -421,7 +421,7 @@ async fn transcript_preview_for_history_mode( let mut first_page_lines = Vec::new(); append_transcript_preview_lines( &mut first_page_lines, - first_page.data.iter().map(|entry| &entry.item), + first_page.data.iter().map(|entry| &entry.item.payload), codex_home.path(), /*inline_visualization_context*/ None, ); diff --git a/codex-rs/tui/src/temporary_structured_request.rs b/codex-rs/tui/src/temporary_structured_request.rs index 8722aaa325ee..840eafb2bae3 100644 --- a/codex-rs/tui/src/temporary_structured_request.rs +++ b/codex-rs/tui/src/temporary_structured_request.rs @@ -9,7 +9,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -216,7 +216,7 @@ pub(crate) async fn collect_structured_response( while let Some(notification) = notifications.recv().await { match notification { ServerNotification::ItemCompleted(completed) if completed.turn_id == turn_id => { - if let ThreadItem::AgentMessage { text, .. } = completed.item { + if let ThreadItemPayload::AgentMessage { text, .. } = completed.item.payload { if text.len() > STRUCTURED_RESPONSE_MAX_BYTES { return Err(eyre!( "temporary structured response exceeds {STRUCTURED_RESPONSE_MAX_BYTES} bytes" diff --git a/codex-rs/tui/src/temporary_structured_request_tests.rs b/codex-rs/tui/src/temporary_structured_request_tests.rs index e86668e5ad75..0cec33781576 100644 --- a/codex-rs/tui/src/temporary_structured_request_tests.rs +++ b/codex-rs/tui/src/temporary_structured_request_tests.rs @@ -10,7 +10,7 @@ use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; @@ -23,8 +23,10 @@ use tempfile::tempdir; use tokio::sync::mpsc::unbounded_channel; fn agent_message_notification(turn_id: &str, text: &str) -> ServerNotification { - ServerNotification::ItemCompleted(ItemCompletedNotification { - item: ThreadItem::AgentMessage { + ServerNotification::ItemCompleted(ItemCompletedNotification::new( + "thread-1".to_string(), + turn_id.to_string(), + ThreadItemPayload::AgentMessage { id: "message-1".to_string(), text: text.to_string(), phase: None, @@ -32,10 +34,9 @@ fn agent_message_notification(turn_id: &str, text: &str) -> ServerNotification { delivery: None, questions: None, }, - thread_id: "thread-1".to_string(), - turn_id: turn_id.to_string(), - completed_at_ms: 0, - }) + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + )) } fn turn_completed_notification(turn_id: &str, status: TurnStatus) -> ServerNotification { diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index babf331fa2ba..9327c77e77fb 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -17,7 +17,7 @@ use crate::history_cell::split_reasoning_summary_parts; use crate::inline_visualization::InlineVisualizationContext; use crate::legacy_core::config::Config; use codex_app_server_protocol::Thread; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::UserInput; use codex_protocol::ThreadId; use codex_protocol::items::UserMessageItem; @@ -107,7 +107,7 @@ pub(crate) fn thread_to_transcript_cells( thread_items_to_transcript_cells( thread_id, &cwd, - turn.items, + turn.items.into_iter().map(|entry| entry.payload), raw_reasoning_visibility, config, ) @@ -124,7 +124,7 @@ pub(crate) fn thread_to_transcript_cells( pub(crate) fn thread_items_to_transcript_cells( thread_id: Option, cwd: &AbsolutePathBuf, - items: impl IntoIterator, + items: impl IntoIterator, raw_reasoning_visibility: RawReasoningVisibility, config: Option<&Config>, ) -> TranscriptCells { @@ -134,7 +134,7 @@ pub(crate) fn thread_items_to_transcript_cells( let mut cells: TranscriptCells = Vec::new(); let mut pending = None; for item in items { - if matches!(item, ThreadItem::Reasoning { .. }) + if matches!(item, ThreadItemPayload::Reasoning { .. }) && let Some(group) = &mut pending { for cell in item_to_cells( @@ -156,7 +156,7 @@ pub(crate) fn thread_items_to_transcript_cells( continue; } match item { - item @ ThreadItem::McpToolCall { .. } => { + item @ ThreadItemPayload::McpToolCall { .. } => { let Some(call) = tools::McpHistory::from_item(item) else { PendingActivity::flush(&mut pending, &mut cells); continue; @@ -179,7 +179,7 @@ pub(crate) fn thread_items_to_transcript_cells( cells.push(Arc::new(cell)); } } - item @ ThreadItem::CommandExecution { .. } => { + item @ ThreadItemPayload::CommandExecution { .. } => { if matches!(pending, Some(PendingActivity::Computer(_))) { PendingActivity::flush(&mut pending, &mut cells); } @@ -220,14 +220,14 @@ pub(crate) fn thread_items_to_transcript_cells( /// Project one item without changing the active widget or its turn lifecycle. fn item_to_cells( - item: ThreadItem, + item: ThreadItemPayload, cwd: &AbsolutePathBuf, raw_reasoning_visibility: RawReasoningVisibility, inline_visualization_context: Option, ) -> TranscriptCells { let mut cells: TranscriptCells = Vec::new(); match item { - ThreadItem::UserMessage { + ThreadItemPayload::UserMessage { id, client_id, content, @@ -266,7 +266,7 @@ fn item_to_cells( remote_image_urls: item.image_urls(), })); } - ThreadItem::AgentMessage { text, .. } => { + ThreadItemPayload::AgentMessage { text, .. } => { let parsed = parse_assistant_markdown(&text, cwd.as_path()); if !parsed.visible_markdown.trim().is_empty() { cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations( @@ -276,7 +276,7 @@ fn item_to_cells( ))); } } - ThreadItem::FunctionCallOutput { + ThreadItemPayload::FunctionCallOutput { name, namespace, output, @@ -296,7 +296,7 @@ fn item_to_cells( ))); } } - ThreadItem::Plan { text, .. } => { + ThreadItemPayload::Plan { text, .. } => { if !text.trim().is_empty() { cells.push(Arc::new(crate::history_cell::new_proposed_plan( text, @@ -304,7 +304,7 @@ fn item_to_cells( ))); } } - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id, summary, content, @@ -329,7 +329,7 @@ fn item_to_cells( cells.push(Arc::new(cell)); } } - item @ ThreadItem::CommandExecution { .. } => { + item @ ThreadItemPayload::CommandExecution { .. } => { if let Some(command) = tools::CommandHistory::from_item(item) { cells.push(Arc::new(command.into_cell())); } diff --git a/codex-rs/tui/src/thread_transcript/computer_groups.rs b/codex-rs/tui/src/thread_transcript/computer_groups.rs index 90efce872e3d..59d33fe45ce1 100644 --- a/codex-rs/tui/src/thread_transcript/computer_groups.rs +++ b/codex-rs/tui/src/thread_transcript/computer_groups.rs @@ -4,7 +4,7 @@ use super::tools::McpHistory; use crate::history_cell::ComputerActivityCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use std::sync::Arc; @@ -40,7 +40,7 @@ pub(crate) fn older_computer_group( let items = adjacent_items(older, newer, turns)?; let older_items = items .iter() - .filter(|item| !matches!(item, ThreadItem::Reasoning { .. })) + .filter(|entry| !matches!(&entry.payload, ThreadItemPayload::Reasoning { .. })) .take(older.call_ids().count()) .cloned() .collect::>(); @@ -53,7 +53,7 @@ fn adjacent_items<'a>( older: &ComputerActivityCell, newer: &ComputerActivityCell, turns: &'a [Turn], -) -> Option<&'a [ThreadItem]> { +) -> Option<&'a [codex_app_server_protocol::ThreadItem]> { let ids = older.call_ids().chain(newer.call_ids()).collect::>(); adjacent_activity_items(&ids, turns) } @@ -62,7 +62,7 @@ fn adjacent_items<'a>( pub(super) fn adjacent_activity_items<'a>( ids: &[&str], turns: &'a [Turn], -) -> Option<&'a [ThreadItem]> { +) -> Option<&'a [codex_app_server_protocol::ThreadItem]> { let first = *ids.first()?; let (turn, start) = turns.iter().find_map(|turn| { turn.items @@ -71,9 +71,9 @@ pub(super) fn adjacent_activity_items<'a>( .map(|start| (turn, start)) })?; let mut matched = 0; - for (offset, item) in turn.items[start..].iter().enumerate() { + for (offset, item) in turn.item_payloads().skip(start).enumerate() { if item.id() != ids[matched] { - if matches!(item, ThreadItem::Reasoning { .. }) { + if matches!(item, ThreadItemPayload::Reasoning { .. }) { continue; } return None; @@ -86,10 +86,13 @@ pub(super) fn adjacent_activity_items<'a>( None } -pub(super) fn completed_group(items: &[ThreadItem]) -> Option { +pub(super) fn completed_group( + items: &[codex_app_server_protocol::ThreadItem], +) -> Option { let mut group = None; - for item in items { - if matches!(item, ThreadItem::Reasoning { .. }) { + for entry in items { + let item = &entry.payload; + if matches!(item, ThreadItemPayload::Reasoning { .. }) { continue; } let call = McpHistory::from_item(item.clone())?; diff --git a/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs b/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs index 6e24c27ef5fe..9d58bd7d73f4 100644 --- a/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs +++ b/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs @@ -5,12 +5,13 @@ use crate::thread_transcript::RawReasoningVisibility; use crate::thread_transcript::TranscriptCells; use crate::thread_transcript::thread_items_to_transcript_cells; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -fn computer(id: &str) -> ThreadItem { +fn computer(id: &str) -> ThreadItemPayload { serde_json::from_value(serde_json::json!({ "type": "mcpToolCall", "id": id, "server": "cua_repl", "tool": "js", "status": "completed", "arguments": {"title": format!("Inspect {id}"), "code": "await cua.getState()"}, @@ -20,11 +21,11 @@ fn computer(id: &str) -> ThreadItem { })).unwrap() } -fn turn(id: &str, items: Vec) -> Turn { +fn turn(id: &str, items: Vec) -> Turn { Turn { id: id.to_string(), root_turn_id: None, - items, + items: items.into_iter().map(ThreadItem::untimed).collect(), items_view: TurnItemsView::Full, status: TurnStatus::Completed, error: None, @@ -34,11 +35,11 @@ fn turn(id: &str, items: Vec) -> Turn { } } -fn project(items: &[ThreadItem]) -> TranscriptCells { +fn project(items: &[impl std::borrow::Borrow]) -> TranscriptCells { thread_items_to_transcript_cells( /*thread_id*/ None, &AbsolutePathBuf::current_dir().unwrap(), - items.to_vec(), + items.iter().map(|entry| entry.borrow().clone()), RawReasoningVisibility::Hidden, /*config*/ None, ) @@ -50,14 +51,14 @@ fn every_page_split_retains_the_same_rich_computer_group() { "turn", vec![ computer("first"), - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "reasoning-first".to_owned(), summary: vec!["Inspect the second screen".to_owned()], content: Vec::new(), }, computer("second"), computer("third"), - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "reasoning-second".to_owned(), summary: vec!["Confirm the final screen".to_owned()], content: Vec::new(), @@ -131,7 +132,7 @@ fn joins_respect_actual_turns_and_intervening_items() { assert!(join_computer_groups(&older[0], &newer[0], &turns).is_none()); let grouped = project(&[ first.clone(), - ThreadItem::Sleep(codex_app_server_protocol::SleepItem { + ThreadItemPayload::Sleep(codex_app_server_protocol::SleepItem { id: "sleep".into(), duration_ms: 10, }), @@ -147,7 +148,7 @@ fn joins_respect_actual_turns_and_intervening_items() { "turn", vec![ first, - ThreadItem::Reasoning { + ThreadItemPayload::Reasoning { id: "reasoning".to_string(), summary: Vec::new(), content: Vec::new(), @@ -162,13 +163,13 @@ fn joins_respect_actual_turns_and_intervening_items() { joined.transcript_lines(/*width*/ 80), expected[0].transcript_lines(/*width*/ 80), ); - turns[0].items[1] = ThreadItem::AgentMessage { + turns[0].items[1] = ThreadItem::untimed(ThreadItemPayload::AgentMessage { id: "message".to_string(), text: "Visible boundary".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, - }; + }); assert!(join_computer_groups(&older[0], &newer[0], &turns).is_none()); } diff --git a/codex-rs/tui/src/thread_transcript/exploration_groups.rs b/codex-rs/tui/src/thread_transcript/exploration_groups.rs index 5b6194e5f2c4..b61e1234e3dd 100644 --- a/codex-rs/tui/src/thread_transcript/exploration_groups.rs +++ b/codex-rs/tui/src/thread_transcript/exploration_groups.rs @@ -3,7 +3,7 @@ use super::tools::CommandHistory; use crate::exec_cell::ExecCell; use crate::history_cell::HistoryCell; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use std::sync::Arc; @@ -33,7 +33,7 @@ pub(crate) fn older_exploration_group( let items = adjacent_items(older, newer, turns)?; let older_items = items .iter() - .filter(|item| !matches!(item, ThreadItem::Reasoning { .. })) + .filter(|entry| !matches!(&entry.payload, ThreadItemPayload::Reasoning { .. })) .take(older.group.calls.len()) .cloned() .collect::>(); @@ -46,7 +46,7 @@ fn adjacent_items<'a>( older: &ExecCell, newer: &ExecCell, turns: &'a [Turn], -) -> Option<&'a [ThreadItem]> { +) -> Option<&'a [codex_app_server_protocol::ThreadItem]> { if !older.is_exploring_cell() || older.should_flush() || !newer.is_exploring_cell() { return None; } @@ -58,10 +58,11 @@ fn adjacent_items<'a>( super::computer_groups::adjacent_activity_items(&ids, turns) } -pub(super) fn completed_group(items: &[ThreadItem]) -> Option { +pub(super) fn completed_group(items: &[codex_app_server_protocol::ThreadItem]) -> Option { let mut group: Option = None; - for item in items { - if matches!(item, ThreadItem::Reasoning { .. }) { + for entry in items { + let item = &entry.payload; + if matches!(item, ThreadItemPayload::Reasoning { .. }) { continue; } let call = CommandHistory::from_item(item.clone())?.into_cell(); diff --git a/codex-rs/tui/src/thread_transcript/other_items.rs b/codex-rs/tui/src/thread_transcript/other_items.rs index 3d81922dfaa0..b5f87b7b26ac 100644 --- a/codex-rs/tui/src/thread_transcript/other_items.rs +++ b/codex-rs/tui/src/thread_transcript/other_items.rs @@ -9,16 +9,16 @@ use crate::history_cell; use crate::history_cell::PlainHistoryCell; use crate::multi_agents; use codex_app_server_protocol::PatchApplyStatus; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::WebSearchAction; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize as _; use std::sync::Arc; -pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells { +pub(super) fn cells(item: ThreadItemPayload, cwd: &AbsolutePathBuf) -> TranscriptCells { let mut cells: TranscriptCells = Vec::new(); match item { - ThreadItem::FileChange { + ThreadItemPayload::FileChange { id, changes, status, @@ -50,15 +50,17 @@ pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells ))); } }, - ThreadItem::WebSearch(item) => cells.push(Arc::new(history_cell::new_web_search_call( - item.id, - item.query, - item.action.unwrap_or(WebSearchAction::Other), - ))), - ThreadItem::ImageView { id, path } => { + ThreadItemPayload::WebSearch(item) => { + cells.push(Arc::new(history_cell::new_web_search_call( + item.id, + item.query, + item.action.unwrap_or(WebSearchAction::Other), + ))) + } + ThreadItemPayload::ImageView { id, path } => { cells.push(Arc::new(history_cell::new_view_image_tool_call(id, path))) } - ThreadItem::ImageGeneration(item) + ThreadItemPayload::ImageGeneration(item) if !matches!(item.status.as_str(), "completed" | "failed") => { let status = if item.status.is_empty() { @@ -71,7 +73,7 @@ pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells /*hint*/ None, ))); } - ThreadItem::ImageGeneration(item) => { + ThreadItemPayload::ImageGeneration(item) => { cells.push(Arc::new(history_cell::new_image_generation_call( item.id, &item.status, @@ -79,7 +81,7 @@ pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells item.saved_path, ))); } - item @ ThreadItem::CollabAgentToolCall { .. } => { + item @ ThreadItemPayload::CollabAgentToolCall { .. } => { if let Some(cell) = multi_agents::tool_call_history_cell( &item, /*cached_spawn_request*/ None, @@ -88,29 +90,29 @@ pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells cells.push(Arc::new(cell)); } } - item @ ThreadItem::SubAgentActivity { .. } => { + item @ ThreadItemPayload::SubAgentActivity { .. } => { if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) { cells.push(Arc::new(cell)); } } - ThreadItem::EnteredReviewMode { review, .. } => { + ThreadItemPayload::EnteredReviewMode { review, .. } => { cells.push(Arc::new(history_cell::new_review_status_line(format!( ">> Code review started: {review} <<" )))); } - ThreadItem::ExitedReviewMode { review, .. } => { + ThreadItemPayload::ExitedReviewMode { review, .. } => { cells.push(Arc::new(history_cell::new_review_status_line(format!( "<< Code review finished: {review} >>" )))); } - ThreadItem::ContextCompaction { .. } => { + ThreadItemPayload::ContextCompaction { .. } => { cells.push(Arc::new(history_cell::new_info_event( "Context compacted".to_string(), /*hint*/ None, ))); } // These items do not have richer history-cell presentations yet. - ThreadItem::HookPrompt { fragments, .. } => { + ThreadItemPayload::HookPrompt { fragments, .. } => { if !fragments.is_empty() { cells.push(Arc::new(PlainHistoryCell::new( fragments @@ -126,19 +128,19 @@ pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells ))); } } - item @ ThreadItem::DynamicToolCall { .. } => { + item @ ThreadItemPayload::DynamicToolCall { .. } => { if let Some(cell) = history_cell::DynamicToolCallCell::from_item(item) { cells.push(Arc::new(cell)); } } - ThreadItem::UserMessage { .. } - | ThreadItem::AgentMessage { .. } - | ThreadItem::FunctionCallOutput { .. } - | ThreadItem::Plan { .. } - | ThreadItem::Reasoning { .. } - | ThreadItem::CommandExecution { .. } - | ThreadItem::McpToolCall { .. } - | ThreadItem::Sleep(_) => {} + ThreadItemPayload::UserMessage { .. } + | ThreadItemPayload::AgentMessage { .. } + | ThreadItemPayload::FunctionCallOutput { .. } + | ThreadItemPayload::Plan { .. } + | ThreadItemPayload::Reasoning { .. } + | ThreadItemPayload::CommandExecution { .. } + | ThreadItemPayload::McpToolCall { .. } + | ThreadItemPayload::Sleep(_) => {} } cells } diff --git a/codex-rs/tui/src/thread_transcript/other_items_tests.rs b/codex-rs/tui/src/thread_transcript/other_items_tests.rs index de095cffcedd..6dfe8ad9ab3e 100644 --- a/codex-rs/tui/src/thread_transcript/other_items_tests.rs +++ b/codex-rs/tui/src/thread_transcript/other_items_tests.rs @@ -12,7 +12,7 @@ use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::SubAgentActivityKind; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::WebSearchAction; use codex_app_server_protocol::WebSearchItem; use codex_utils_path_uri::LegacyAppPathString; @@ -68,7 +68,7 @@ fn completed_patch_restores_rich_diff_and_styles() { ); changes[2].diff.push_str("\n\nMoved to: src/after.rs"); let actual = cells( - ThreadItem::FileChange { + ThreadItemPayload::FileChange { id: "patch-1".to_string(), changes, status: PatchApplyStatus::Completed, @@ -94,7 +94,7 @@ fn unfinished_and_rejected_patches_keep_their_outcome() { .into_iter() .flat_map(|status| { cells( - ThreadItem::FileChange { + ThreadItemPayload::FileChange { id: "patch-1".to_string(), changes: vec![FileUpdateChange { path: "main.rs".to_string(), @@ -136,11 +136,11 @@ fn tool_and_notice_projection_uses_normal_transcript_presentation() { generation_id: None, }; let items = vec![ - ThreadItem::EnteredReviewMode { + ThreadItemPayload::EnteredReviewMode { id: "review-start".to_string(), review: "current changes".to_string(), }, - ThreadItem::WebSearch(WebSearchItem { + ThreadItemPayload::WebSearch(WebSearchItem { id: "search-1".to_string(), query: "fallback query".to_string(), action: Some(WebSearchAction::FindInPage { @@ -149,16 +149,16 @@ fn tool_and_notice_projection_uses_normal_transcript_presentation() { }), results: None, }), - ThreadItem::ImageView { + ThreadItemPayload::ImageView { id: "view-1".to_string(), path: LegacyAppPathString::from_string("diagram.png".to_string()), }, - ThreadItem::ImageGeneration(image.clone()), - ThreadItem::ImageGeneration(ImageGenerationItem { + ThreadItemPayload::ImageGeneration(image.clone()), + ThreadItemPayload::ImageGeneration(ImageGenerationItem { status: "in_progress".into(), ..image }), - ThreadItem::SubAgentActivity { + ThreadItemPayload::SubAgentActivity { model: None, reasoning_effort: None, id: "agent-1".to_string(), @@ -166,11 +166,11 @@ fn tool_and_notice_projection_uses_normal_transcript_presentation() { agent_thread_id: "01912345-1234-7123-8123-123456789abc".to_string(), agent_path: "/root/reviewer".to_string(), }, - ThreadItem::ExitedReviewMode { + ThreadItemPayload::ExitedReviewMode { id: "review-end".to_string(), review: "No findings".to_string(), }, - ThreadItem::ContextCompaction { + ThreadItemPayload::ContextCompaction { id: "compact-1".to_string(), }, ]; diff --git a/codex-rs/tui/src/thread_transcript/tools.rs b/codex-rs/tui/src/thread_transcript/tools.rs index 73e80d617c1b..14fe7b1f0232 100644 --- a/codex-rs/tui/src/thread_transcript/tools.rs +++ b/codex-rs/tui/src/thread_transcript/tools.rs @@ -18,7 +18,7 @@ use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::McpToolCallStatus; -use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadItemPayload; use codex_protocol::mcp::CallToolResult; use codex_protocol::parse_command::ParsedCommand; use ratatui::style::Stylize as _; @@ -27,9 +27,9 @@ use ratatui::text::Line; /// Preserve status and output when replay cannot reconstruct a rich completed tool cell. /// Declined commands never ran, and historical terminal interactions do not carry stdin details. /// Pending items retain their last known state without starting a running clock. -pub(crate) fn historical_tool_fallback(item: &ThreadItem) -> Option { +pub(crate) fn historical_tool_fallback(item: &ThreadItemPayload) -> Option { let lines = match item { - ThreadItem::CommandExecution { + ThreadItemPayload::CommandExecution { command, source, status, @@ -73,7 +73,7 @@ pub(crate) fn historical_tool_fallback(item: &ThreadItem) -> Option Option Option { - let ThreadItem::CommandExecution { + pub(crate) fn from_item(item: ThreadItemPayload) -> Option { + let ThreadItemPayload::CommandExecution { id, command, source, @@ -192,8 +192,8 @@ pub(crate) struct McpHistory { } impl McpHistory { - pub(crate) fn from_item(item: ThreadItem) -> Option { - let ThreadItem::McpToolCall { + pub(crate) fn from_item(item: ThreadItemPayload) -> Option { + let ThreadItemPayload::McpToolCall { id, server, tool, diff --git a/codex-rs/tui/src/thread_transcript/tools_tests.rs b/codex-rs/tui/src/thread_transcript/tools_tests.rs index 267168c87674..c3249b161489 100644 --- a/codex-rs/tui/src/thread_transcript/tools_tests.rs +++ b/codex-rs/tui/src/thread_transcript/tools_tests.rs @@ -9,8 +9,8 @@ use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use serde_json::json; -fn command_item(status: CommandExecutionStatus) -> ThreadItem { - ThreadItem::CommandExecution { +fn command_item(status: CommandExecutionStatus) -> ThreadItemPayload { + ThreadItemPayload::CommandExecution { id: "command".to_string(), plugin_id: None, script_path: None, @@ -35,8 +35,8 @@ fn command_item(status: CommandExecutionStatus) -> ThreadItem { } } -fn mcp_item(server: &str, id: &str) -> ThreadItem { - ThreadItem::McpToolCall { +fn mcp_item(server: &str, id: &str) -> ThreadItemPayload { + ThreadItemPayload::McpToolCall { id: id.to_string(), server: server.to_string(), tool: "js".to_string(), @@ -100,7 +100,7 @@ fn completed_tools_keep_compact_and_detailed_presentations() { fn failed_commands_keep_failure_when_exit_code_is_missing_or_zero() { for exit in [None, Some(0)] { let mut item = command_item(CommandExecutionStatus::Failed); - if let ThreadItem::CommandExecution { exit_code, .. } = &mut item { + if let ThreadItemPayload::CommandExecution { exit_code, .. } = &mut item { *exit_code = exit; } let command = CommandHistory::from_item(item).unwrap(); @@ -112,12 +112,12 @@ fn failed_commands_keep_failure_when_exit_code_is_missing_or_zero() { fn incomplete_tool_payloads_preserve_last_known_status_and_output() { let command = command_item(CommandExecutionStatus::InProgress); let mut mcp = mcp_item("example", "pending"); - if let ThreadItem::McpToolCall { status, result, .. } = &mut mcp { + if let ThreadItemPayload::McpToolCall { status, result, .. } = &mut mcp { *status = McpToolCallStatus::InProgress; *result = None; } let mut missing_result = mcp_item("example", "completed"); - if let ThreadItem::McpToolCall { result, .. } = &mut missing_result { + if let ThreadItemPayload::McpToolCall { result, .. } = &mut missing_result { *result = None; } let cwd = test_path_buf("/workspace").abs(); @@ -141,7 +141,7 @@ fn historical_command_fallbacks_preserve_status_output_and_group_boundaries() { let cwd = test_path_buf("/workspace").abs(); let exploration = |call_id: &str| { let mut item = command_item(CommandExecutionStatus::Completed); - if let ThreadItem::CommandExecution { + if let ThreadItemPayload::CommandExecution { id, command, command_actions, @@ -204,7 +204,7 @@ fn historical_command_fallbacks_preserve_status_output_and_group_boundaries() { ), ] { let mut item = command_item(status); - if let ThreadItem::CommandExecution { + if let ThreadItemPayload::CommandExecution { source: item_source, command, aggregated_output, @@ -305,7 +305,7 @@ fn agent_tool_fallbacks_preserve_status_without_duplicating_v2_activity() { | CollabAgentTool::ResumeAgent | CollabAgentTool::Wait ); - let item = ThreadItem::CollabAgentToolCall { + let item = ThreadItemPayload::CollabAgentToolCall { id: "pending-agent-call".to_string(), tool, status, diff --git a/codex-rs/tui/src/transcript_view/mutations_tests.rs b/codex-rs/tui/src/transcript_view/mutations_tests.rs index 25edcdf7b002..fa797408345f 100644 --- a/codex-rs/tui/src/transcript_view/mutations_tests.rs +++ b/codex-rs/tui/src/transcript_view/mutations_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::history_cell::PlainHistoryCell; +use codex_app_server_protocol::ThreadItem; use pretty_assertions::assert_eq; fn cell(text: &str) -> Arc { @@ -360,7 +361,7 @@ fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { use crate::thread_transcript::RawReasoningVisibility; use crate::thread_transcript::fold_trailing_activity_details; use crate::thread_transcript::thread_items_to_transcript_cells; - use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; @@ -371,7 +372,7 @@ fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { "result": {"content": [{"type": "text", "text": "retained call output"}]}, "durationMs": 1 })) .expect("computer call"); - let reasoning = ThreadItem::Reasoning { + let reasoning = ThreadItemPayload::Reasoning { id: "reasoning".to_owned(), summary: vec!["retained needle text with exact spacing".to_owned()], content: Vec::new(), @@ -379,7 +380,7 @@ fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { let current = Turn { id: "turn".to_owned(), root_turn_id: None, - items: vec![call, reasoning], + items: vec![call, ThreadItem::untimed(reasoning)], items_view: TurnItemsView::Full, status: TurnStatus::Completed, error: None, @@ -387,11 +388,11 @@ fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { completed_at: None, duration_ms: None, }; - let project = |items: &[ThreadItem]| { + let project = |items: &[codex_app_server_protocol::ThreadItem]| { thread_items_to_transcript_cells( /*thread_id*/ None, &codex_utils_absolute_path::AbsolutePathBuf::current_dir().unwrap(), - items.iter().cloned(), + items.iter().map(|entry| entry.payload.clone()), RawReasoningVisibility::Hidden, /*config*/ None, ) diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index d03cdbe34545..de3734c3810a 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -529,6 +529,13 @@ def _normalized_schema_bundle_text(schema_dir: Path) -> str: _preserve_guardian_approval_path_wrappers(schema) definitions = schema.get("definitions", {}) if isinstance(definitions, dict): + # Pydantic represents the tagged item union as its existing nominal variant classes. + # Give each variant the shared fields so live and persisted items parse identically. + thread_item = definitions.get("ThreadItem", {}) + timing = thread_item.pop("properties", {}) + for variant in thread_item.get("oneOf", []): + variant["properties"].update(timing) + # Older SDKs accept omission for these nullable project and section IDs. for model, field in ( ("Thread", "projectId"), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 6bceb284b930..3af00d6150c2 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -5309,8 +5309,22 @@ class HookPromptThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None fragments: list[HookPromptFragment] id: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["hookPrompt"], Field(title="HookPromptThreadItemType")] @@ -5318,7 +5332,21 @@ class PlanThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None text: str type: Annotated[Literal["plan"], Field(title="PlanThreadItemType")] @@ -5327,8 +5355,22 @@ class ReasoningThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None content: list[str] | None = [] id: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None summary: list[str] | None = [] type: Annotated[Literal["reasoning"], Field(title="ReasoningThreadItemType")] @@ -5339,6 +5381,13 @@ class McpToolCallThreadItem(BaseModel): ) app_context: Annotated[McpToolCallAppContext | None, Field(alias="appContext")] = None arguments: Any + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None duration_ms: Annotated[ int | None, Field(alias="durationMs", description="The duration of the MCP tool call in milliseconds."), @@ -5363,6 +5412,13 @@ class McpToolCallThreadItem(BaseModel): read_only_hint: Annotated[bool | None, Field(alias="readOnlyHint")] = None result: McpToolCallResult | None = None server: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None status: McpToolCallStatus tool: str type: Annotated[Literal["mcpToolCall"], Field(title="McpToolCallThreadItemType")] @@ -5373,6 +5429,13 @@ class DynamicToolCallThreadItem(BaseModel): populate_by_name=True, ) arguments: Any + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None content_items: Annotated[ list[DynamicToolCallOutputContentItem] | None, Field(alias="contentItems") ] = None @@ -5384,6 +5447,13 @@ class DynamicToolCallThreadItem(BaseModel): ] = None id: str namespace: str | None = None + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None status: DynamicToolCallStatus success: bool | None = None tool: str @@ -5396,6 +5466,13 @@ class SubAgentActivityThreadItem(BaseModel): ) agent_path: Annotated[str, Field(alias="agentPath")] agent_thread_id: Annotated[str, Field(alias="agentThreadId")] + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str kind: SubAgentActivityKind model: Annotated[ @@ -5411,6 +5488,13 @@ class SubAgentActivityThreadItem(BaseModel): description="Resolved reasoning effort at sub-agent creation, when known.", ), ] = None + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["subAgentActivity"], Field(title="SubAgentActivityThreadItemType")] @@ -5418,8 +5502,22 @@ class ImageViewThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str path: LegacyAppPathString + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["imageView"], Field(title="ImageViewThreadItemType")] @@ -5427,8 +5525,22 @@ class SleepThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None duration_ms: Annotated[int, Field(alias="durationMs", ge=0)] id: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["sleep"], Field(title="SleepThreadItemType")] @@ -5436,6 +5548,13 @@ class FileIdThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None failure: ImageGenerationFailure | None = None id: str input_images: Annotated[ @@ -5447,6 +5566,13 @@ class FileIdThreadItem(BaseModel): ] = None revised_prompt: Annotated[str | None, Field(alias="revisedPrompt")] = None saved_path: Annotated[AbsolutePathBuf | None, Field(alias="savedPath")] = None + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None status: str transparent_background: Annotated[bool | None, Field(alias="transparentBackground")] = None type: Annotated[Literal["imageGeneration"], Field(title="ImageGenerationThreadItemType")] @@ -5457,6 +5583,13 @@ class ResultThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None failure: ImageGenerationFailure | None = None id: str input_images: Annotated[ @@ -5468,6 +5601,13 @@ class ResultThreadItem(BaseModel): ] = None revised_prompt: Annotated[str | None, Field(alias="revisedPrompt")] = None saved_path: Annotated[AbsolutePathBuf | None, Field(alias="savedPath")] = None + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None status: str transparent_background: Annotated[bool | None, Field(alias="transparentBackground")] = None type: Annotated[Literal["imageGeneration"], Field(title="ImageGenerationThreadItemType")] @@ -5481,8 +5621,22 @@ class EnteredReviewModeThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str review: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["enteredReviewMode"], Field(title="EnteredReviewModeThreadItemType")] @@ -5490,8 +5644,22 @@ class ExitedReviewModeThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str review: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["exitedReviewMode"], Field(title="ExitedReviewModeThreadItemType")] @@ -5499,7 +5667,21 @@ class ContextCompactionThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["contextCompaction"], Field(title="ContextCompactionThreadItemType")] @@ -9993,8 +10175,22 @@ class UserMessageThreadItem(BaseModel): populate_by_name=True, ) client_id: Annotated[str | None, Field(alias="clientId")] = None + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None content: list[UserInput] id: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")] @@ -10002,11 +10198,25 @@ class AgentMessageThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None delivery: AgentMessageDelivery | None = None id: str memory_citation: Annotated[MemoryCitation | None, Field(alias="memoryCitation")] = None phase: MessagePhase | None = None questions: list[AsyncUserInputQuestion] | None = None + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None text: str type: Annotated[Literal["agentMessage"], Field(title="AgentMessageThreadItemType")] @@ -10030,6 +10240,13 @@ class CommandExecutionThreadItem(BaseModel): description="A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", ), ] + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None cwd: Annotated[LegacyAppPathString, Field(description="The command's working directory.")] duration_ms: Annotated[ int | None, @@ -10063,6 +10280,13 @@ class CommandExecutionThreadItem(BaseModel): ), ] = None source: CommandExecutionSource | None = "agent" + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None status: CommandExecutionStatus type: Annotated[Literal["commandExecution"], Field(title="CommandExecutionThreadItemType")] @@ -10072,7 +10296,21 @@ class FileChangeThreadItem(BaseModel): populate_by_name=True, ) changes: list[FileUpdateChange] + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None status: PatchApplyStatus type: Annotated[Literal["fileChange"], Field(title="FileChangeThreadItemType")] @@ -10088,6 +10326,13 @@ class CollabAgentToolCallThreadItem(BaseModel): description="Last known status of the target agents, when available.", ), ] + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: Annotated[str, Field(description="Unique identifier for this collab tool call.")] model: Annotated[ str | None, Field(description="Model requested for the spawned agent, when applicable.") @@ -10116,6 +10361,13 @@ class CollabAgentToolCallThreadItem(BaseModel): alias="senderThreadId", description="Thread ID of the agent issuing the collab request." ), ] + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None status: Annotated[ CollabAgentToolCallStatus, Field(description="Current status of the collab tool call.") ] @@ -10130,6 +10382,13 @@ class WebSearchThreadItem(BaseModel): populate_by_name=True, ) action: WebSearchAction | None = None + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str query: str results: Annotated[ @@ -10138,6 +10397,13 @@ class WebSearchThreadItem(BaseModel): description="Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release." ), ] = None + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["webSearch"], Field(title="WebSearchThreadItemType")] @@ -11575,10 +11841,24 @@ class FunctionCallOutputThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + completed_at_ms: Annotated[ + int | None, + Field( + alias="completedAtMs", + description="Unix timestamp in milliseconds when the item completed, if recorded.", + ), + ] = None id: str name: str namespace: str | None = None output: FunctionCallOutputBody + started_at_ms: Annotated[ + int | None, + Field( + alias="startedAtMs", + description="Unix timestamp in milliseconds when the item started, if recorded.", + ), + ] = None type: Annotated[Literal["functionCallOutput"], Field(title="FunctionCallOutputThreadItemType")] @@ -11606,7 +11886,7 @@ class ThreadItem( | ContextCompactionThreadItem ] ): - root: ( + root: Annotated[ UserMessageThreadItem | HookPromptThreadItem | AgentMessageThreadItem @@ -11626,8 +11906,11 @@ class ThreadItem( | ResultThreadItem | EnteredReviewModeThreadItem | ExitedReviewModeThreadItem - | ContextCompactionThreadItem - ) + | ContextCompactionThreadItem, + Field( + description="A thread item and its recorded lifecycle times. The payload stays flat on the wire." + ), + ] class ThreadItemEntry(BaseModel): diff --git a/sdk/python/tests/test_app_server_inputs.py b/sdk/python/tests/test_app_server_inputs.py index 26e0bfa65927..0bc910009111 100644 --- a/sdk/python/tests/test_app_server_inputs.py +++ b/sdk/python/tests/test_app_server_inputs.py @@ -16,6 +16,8 @@ SkillInput, TextInput, ) +from openai_codex.generated.v2_all import FunctionCallOutputThreadItem +from openai_codex.types import ThreadItem def _external_items(request) -> list[dict]: @@ -49,7 +51,16 @@ def test_external_message_preserves_tool_authority_through_resume(tmp_path) -> N with Codex(config=harness.app_server_config()) as codex: resumed = codex.thread_resume(thread.id, include_turns=False) history = resumed.read(include_turns=True) - assert external_item in history.thread.turns[0].items + persisted = next( + item + for item in history.thread.turns[0].items + if item.root.id == external_item.root.id + ) + assert isinstance(persisted, ThreadItem) + assert isinstance(persisted.root, FunctionCallOutputThreadItem) + assert persisted.root.started_at_ms is not None + assert persisted.root.completed_at_ms is not None + assert persisted == external_item resumed.run("Summarize the external update.") requests = harness.responses.requests() From 619531225b29dcf6437ec6571537f9da9da002bc Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 23 Oct 2026 19:46:07 +0000 Subject: [PATCH 010/287] Add scoped request-body secret substitution to MITM hooks (#57174) ## What changed - Add `body_secret_substitution` actions to version 2 MITM configuration, replacing exact opaque placeholders with secrets from environment variables or files within the matching hook's scope. - Bound body size, expansion, replacement count, and search work. Reject overlapping matches, compressed payloads, trailers, and supplied payload-integrity headers. - Recompute `Content-Length` and substitute before SigV4 signing so signatures cover the transmitted body. ## Testing Add coverage for markers split across chunks, expansion limits, invalid payloads, overlapping bindings, secret redaction, and search limits. Add a TLS forwarding test that verifies hook scope and independently checks the signature of the substituted body. GitOrigin-RevId: fa95c9ae0c8fc8f695e03c0e87a0a4875d8418fd --- codex-rs/config/src/permissions_toml.rs | 28 +- codex-rs/config/src/permissions_toml_tests.rs | 19 +- codex-rs/core/config.schema.json | 52 ++++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/network-proxy/README.md | 18 +- codex-rs/network-proxy/src/lib.rs | 3 + codex-rs/network-proxy/src/mitm.rs | 10 + .../src/mitm_forwarding_tests.rs | 112 ++++++++ codex-rs/network-proxy/src/mitm_hook.rs | 18 ++ codex-rs/network-proxy/src/mitm_tests.rs | 2 + .../network-proxy/src/secret_substitution.rs | 264 ++++++++++++++++++ .../src/secret_substitution_tests.rs | 261 +++++++++++++++++ 12 files changed, 784 insertions(+), 5 deletions(-) create mode 100644 codex-rs/network-proxy/src/secret_substitution.rs create mode 100644 codex-rs/network-proxy/src/secret_substitution_tests.rs diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index 00446f0419eb..66843625ef6a 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -397,6 +397,7 @@ enum NetworkModeSchema { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct NetworkMitmActionToml { + pub body_secret_substitution: Option, pub aws_sigv4: Option, pub strip_request_headers: Vec, pub inject_request_headers: Vec, @@ -446,7 +447,11 @@ impl NetworkMitmToml { .inject_request_headers .iter() .any(|header| header.placeholder.is_some()); - if self.version.is_none() && (uses_placeholder || action.aws_sigv4.is_some()) { + if self.version.is_none() + && (uses_placeholder + || action.aws_sigv4.is_some() + || action.body_secret_substitution.is_some()) + { return Err("scoped MITM actions require network.mitm.version = 2".to_string()); } if action.is_empty() { @@ -477,6 +482,21 @@ impl NetworkMitmToml { "network.mitm.hooks.{hook_name} must select at most one AWS signing action" )); } + let substitution_count = hook + .action + .iter() + .filter(|name| { + self.actions + .as_ref() + .and_then(|actions| actions.get(*name)) + .is_some_and(|action| action.body_secret_substitution.is_some()) + }) + .count(); + if substitution_count > 1 { + return Err(format!( + "network.mitm.hooks.{hook_name} must select at most one body substitution action" + )); + } if self.version.is_none() && (!hook.schemes.is_empty() || !hook.ports.is_empty()) { return Err("scoped MITM hooks require network.mitm.version = 2".to_string()); } @@ -508,7 +528,8 @@ impl NetworkMitmToml { impl NetworkMitmActionToml { pub fn is_empty(&self) -> bool { - self.aws_sigv4.is_none() + self.body_secret_substitution.is_none() + && self.aws_sigv4.is_none() && self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty() } @@ -608,6 +629,9 @@ impl NetworkMitmHookToml { let mut selected = MitmHookActionsConfig::default(); for action_name in &self.action { if let Some(action) = actions_by_name.get(action_name) { + if action.body_secret_substitution.is_some() { + selected.body_secret_substitution = action.body_secret_substitution.clone(); + } if action.aws_sigv4.is_some() { selected.aws_sigv4 = action.aws_sigv4.clone(); } diff --git a/codex-rs/config/src/permissions_toml_tests.rs b/codex-rs/config/src/permissions_toml_tests.rs index f3149d4ed896..ed3944574895 100644 --- a/codex-rs/config/src/permissions_toml_tests.rs +++ b/codex-rs/config/src/permissions_toml_tests.rs @@ -19,13 +19,18 @@ name = "x-api-key" secret_env_var = "PROXY_API_KEY" placeholder = "dummy-key" +[actions.body.body_secret_substitution] +port = 443 +max_body_bytes = 1024 +replacements = [{ placeholder = "codex-secret-0123456789abcdef", secret = { env_var = "BODY_SECRET" } }] + [hooks.bucket] host = "bucket.s3.amazonaws.com" schemes = ["https"] ports = [443] methods = ["PUT"] path_prefixes = ["/workspace/"] -action = ["substitute", "aws"] +action = ["substitute", "body", "aws"] "#; #[test] @@ -35,6 +40,16 @@ fn resolves_native_signing_and_substitution_actions_from_toml() { assert_eq!(hooks[0].matcher.schemes, vec!["https"]); assert_eq!(hooks[0].matcher.ports, vec![443]); let signer = hooks[0].actions.aws_sigv4.as_ref().unwrap(); + assert_eq!( + hooks[0] + .actions + .body_secret_substitution + .as_ref() + .unwrap() + .replacements[0] + .secret, + ProxySecretSource::EnvVar("BODY_SECRET".into()) + ); assert_eq!( signer.access_key_id, ProxySecretSource::EnvVar("PROXY_ACCESS_KEY".into()) @@ -110,7 +125,7 @@ fn rejects_misspelled_header_placeholder() { fn rejects_multiple_signing_actions_and_unknown_signing_parameters() { assert!( toml::from_str::(&SIGNING.replace( - "action = [\"substitute\", \"aws\"]", + "action = [\"substitute\", \"body\", \"aws\"]", "action = [\"aws\", \"aws\"]" )) .is_err() diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2c9b7a3f514c..264e19c8e075 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -566,6 +566,50 @@ ], "type": "object" }, + "BodySecretReplacement": { + "additionalProperties": false, + "properties": { + "placeholder": { + "type": "string" + }, + "secret": { + "$ref": "#/definitions/ProxySecretSource" + } + }, + "required": [ + "placeholder", + "secret" + ], + "type": "object" + }, + "BodySecretSubstitutionConfig": { + "additionalProperties": false, + "description": "Exact opaque placeholders scoped by their enclosing host/method/path MITM hook.", + "properties": { + "max_body_bytes": { + "default": 8388608, + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "port": { + "default": 443, + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "replacements": { + "items": { + "$ref": "#/definitions/BodySecretReplacement" + }, + "type": "array" + } + }, + "required": [ + "replacements" + ], + "type": "object" + }, "BrowserUseConfigToml": { "additionalProperties": false, "properties": { @@ -3216,6 +3260,14 @@ ], "default": null }, + "body_secret_substitution": { + "allOf": [ + { + "$ref": "#/definitions/BodySecretSubstitutionConfig" + } + ], + "default": null + }, "inject_request_headers": { "default": [], "items": { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ebbdec8aa17d..aa9c3831ed68 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1683,6 +1683,7 @@ strip_request_headers = ["authorization"] actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { + body_secret_substitution: None, aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), @@ -1766,6 +1767,7 @@ fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() { actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { + body_secret_substitution: None, aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 89a0271d7ce1..6556281f73f1 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -309,7 +309,23 @@ An injected header can additionally set `placeholder` to require one exact whole-header value before substituting the secret. Missing, mismatched, or duplicate values fail closed. This form requires HTTPS port 443 and cannot modify routing or framing headers. Resolved secret headers and signing headers -are marked sensitive in diagnostics. +are marked sensitive in diagnostics. Body substitution is available as `body_secret_substitution` with `replacements` +(each containing an exact `placeholder` and a trusted `secret` source), `port`, +and `max_body_bytes`. Markers must be opaque ASCII values of 16–256 bytes. +Input and expanded output are bounded (8 MiB default, 16 MiB maximum), each +resolved credential is capped at 64 KiB, and at most 65,536 occurrences may be +replaced. Search work is capped at 256 MiB of worst-case byte comparisons; +requests may exceed this limit before reaching the body-size limit. +Compressed payloads, trailers, supplied payload-integrity headers, and AWS +streaming signatures fail closed. Markers split across HTTP body chunks are +supported; overlapping +bindings or occurrences are rejected. Replacement is byte-exact and does not +perform JSON/form escaping or regex interpretation. The caller must choose +markers appropriate for the payload encoding. Content length is recomputed, +and SigV4 runs after substitution so it hashes the actual transmitted body. +At most one signing action and one body substitution action may match a request; +combine body bindings in one action to avoid ambiguous transformation order. + All native buffering stages share eight request slots, with a five-second bounded wait. Each lease follows the prepared body until consumed or dropped; the 16 MiB body cap bounds retained memory even when upstream connections are slow. diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 62e3bff2e8de..30cd34bcb329 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -26,6 +26,7 @@ mod request_cancellation; mod request_disconnect; mod responses; mod runtime; +mod secret_substitution; mod socket_path; mod socks5; mod state; @@ -126,6 +127,8 @@ pub use runtime::ConfigReloaderFuture; pub use runtime::ConfigState; pub use runtime::LocalBindingPolicy; pub use runtime::NetworkProxyState; +pub use secret_substitution::BodySecretReplacement; +pub use secret_substitution::BodySecretSubstitutionConfig; pub use state::NetworkProxyAuditMetadata; pub use state::NetworkProxyConstraintError; pub use state::NetworkProxyConstraints; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index a90045a875ca..01784c7be5d7 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -281,6 +281,7 @@ async fn handle_mitm_request( request_ctx: Arc, ) -> Result { crate::aws_sigv4::preserve_signing_headers(&mut req); + crate::secret_substitution::preserve_body_substitution_headers(&mut req); let response = match (request_ctx.policy.scheme == Scheme::HTTPS) .then(RemoveRequestHeaderLayer::hop_by_hop) .into_layer(service_fn(move |req| { @@ -369,6 +370,15 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu }; let mut upstream_req = Request::from_parts(parts, body); + if let Some(substitution) = hook_actions + .as_ref() + .and_then(|actions| actions.body_secret_substitution.as_ref()) + { + let Ok(substituted) = substitution.apply(upstream_req).await else { + return Ok(request_ctx.policy.deny_hook(&method, client).await); + }; + upstream_req = substituted; + } if let Some((signer, guest)) = signing { let Ok(signed) = signer.sign(upstream_req, &guest).await else { return Ok(request_ctx.policy.deny_hook(&method, client).await); diff --git a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs index e920c2253770..057806f36987 100644 --- a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs +++ b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs @@ -36,6 +36,15 @@ async fn tls_origin( dir: &Path, service: &str, alpn: &[u8], +) -> (MitmRequestContext, TcpListener, TlsAcceptor) { + tls_origin_with_hook(dir, service, alpn, |_, _| {}).await +} + +async fn tls_origin_with_hook( + dir: &Path, + service: &str, + alpn: &[u8], + configure: impl FnOnce(&mut MitmHookConfig, u16), ) -> (MitmRequestContext, TcpListener, TlsAcceptor) { ensure_rustls_crypto_provider(); let key = KeyPair::generate().expect("generate origin key"); @@ -94,6 +103,7 @@ async fn tls_origin( }], ..Default::default() }; + configure(&mut config.mitm_hooks[0], port); config.set_credential_broker_enabled(/*enabled*/ true); config.set_allowed_domains(vec!["localhost".to_string()]); let app_state = Arc::new(network_proxy_state_for_policy(config)); @@ -479,3 +489,105 @@ async fn drained_signed_bodies_release_capacity_before_response_headers() { .expect("drained bodies must release buffer slots before response headers"); assert_eq!(response.status(), StatusCode::OK); } + +#[tokio::test] +async fn forwards_substituted_body_before_signing() { + let marker = b"codex-secret-0123456789abcdef"; + let dir = tempdir().expect("TLS fixture directory"); + let secret = dir.path().join("body-secret"); + std::fs::write(&secret, "expanded-payload").unwrap(); + let configure = |hook: &mut MitmHookConfig, port| { + hook.matcher.path_prefixes = vec!["/allowed/".into()]; + hook.actions.strip_request_headers.push("digest".into()); + hook.actions.body_secret_substitution = Some(crate::BodySecretSubstitutionConfig { + replacements: vec![crate::BodySecretReplacement { + placeholder: String::from_utf8(marker.to_vec()).unwrap(), + secret: ProxySecretSource::File(secret.display().to_string()), + }], + port, + max_body_bytes: 64, + }); + }; + let (context, listener, acceptor) = + tls_origin_with_hook(dir.path(), "s3", b"http/1.1", configure).await; + let context = Arc::new(context); + let signed_headers = "content-length;cookie;host;x-amz-date"; + let make_request = |path: &str| { + let mut req = request(&context, "s3", "POST", path, marker); + *req.version_mut() = rama_http::Version::HTTP_2; + let headers = req.headers_mut(); + headers.remove("x-amz-content-sha256"); + headers.insert( + "authorization", + HeaderValue::from_str(&guest_authorization("s3", signed_headers)).unwrap(), + ); + headers.insert("content-length", HeaderValue::from(marker.len())); + headers.append("cookie", HeaderValue::from_static("a=1")); + headers.append("cookie", HeaderValue::from_static("b=2")); + req + }; + let denied = handle_mitm_request(make_request("/denied"), context.clone()) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + let mut invalid = make_request("/allowed/item"); + invalid + .headers_mut() + .insert("digest", HeaderValue::from_static("sha-256=guest")); + let denied = handle_mitm_request(invalid, context.clone()).await.unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + let origin = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut stream = acceptor.accept(socket).await.unwrap(); + let wire = read_request(&mut stream).await; + stream.write_all(RESPONSE).await.unwrap(); + wire + }); + let req = make_request("/allowed/item"); + let response = timeout(FORWARD_TIMEOUT, handle_mitm_request(req, context)) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let wire = origin.await.unwrap(); + assert_eq!(wire.body.as_slice(), b"expanded-payload"); + assert_eq!(wire.path, "/allowed/item"); + let headers = wire.headers; + assert_eq!(headers["cookie"], "a=1; b=2"); + let expected_hash = format!("{:x}", sha2::Sha256::digest(&wire.body)); + assert_eq!(headers["x-amz-content-sha256"], expected_hash); + // Rebuild the canonical request from the received HTTP/1.1 headers, without + // the signer's request preparation or the SDK's header canonicalization. + let canonical_headers = signed_headers + .split(';') + .map(|name| format!("{name}:{}\n", headers[name])) + .collect::(); + let canonical_request = + format!("POST\n/allowed/item\n\n{canonical_headers}\n{signed_headers}\n{expected_hash}"); + let timestamp = &headers["x-amz-date"]; + let signing_time = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y%m%dT%H%M%SZ") + .unwrap() + .and_utc(); + let scope = format!( + "{}/us-east-1/s3/aws4_request", + signing_time.format("%Y%m%d") + ); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{:x}", + sha2::Sha256::digest(canonical_request.as_bytes()) + ); + let signing_key = aws_sigv4::sign::v4::generate_signing_key( + "test-secret-key", + signing_time.into(), + "us-east-1", + "s3", + ); + let signature = + aws_sigv4::sign::v4::calculate_signature(signing_key, string_to_sign.as_bytes()); + assert_eq!( + headers["authorization"], + format!( + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/{scope}, SignedHeaders={signed_headers}, Signature={signature}" + ) + ); +} diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs index 61e385949df5..ca1cde1b8f5d 100644 --- a/codex-rs/network-proxy/src/mitm_hook.rs +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -5,6 +5,8 @@ use crate::aws_sigv4::AwsSigV4Config; use crate::aws_sigv4::AwsSigV4Signer; use crate::config::NetworkProxyConfig; use crate::policy::normalize_host; +use crate::secret_substitution::BodySecretSubstitution; +use crate::secret_substitution::BodySecretSubstitutionConfig; use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; @@ -52,6 +54,7 @@ pub struct MitmHookMatchConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(default)] pub struct MitmHookActionsConfig { + pub body_secret_substitution: Option, pub aws_sigv4: Option, pub strip_request_headers: Vec, pub inject_request_headers: Vec, @@ -104,6 +107,7 @@ pub struct HeaderConstraint { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MitmHookActions { + pub(crate) body_secret_substitution: Option>, pub(crate) aws_sigv4: Option>, pub strip_request_headers: Vec, pub inject_request_headers: Vec, @@ -245,6 +249,9 @@ pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<( if let Some(signing) = &hook.actions.aws_sigv4 { signing.validate()?; } + if let Some(substitution) = &hook.actions.body_secret_substitution { + substitution.validate()?; + } validate_injected_headers(&hook.actions.inject_request_headers).with_context(|| { format!("invalid network.mitm_hooks[{hook_index}].actions.inject_request_headers") })?; @@ -373,6 +380,16 @@ where body: None, }, actions: MitmHookActions { + body_secret_substitution: hook + .actions + .body_secret_substitution + .as_ref() + .map(|substitution| { + substitution + .compile(&resolve_env_var, &read_secret_file) + .map(Arc::new) + }) + .transpose()?, aws_sigv4: hook .actions .aws_sigv4 @@ -803,6 +820,7 @@ mod tests { ..MitmHookMatchConfig::default() }, actions: MitmHookActionsConfig { + body_secret_substitution: None, aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: vec![InjectedHeaderConfig { diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index f3e74e3a3170..d4fcd34ce951 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -29,6 +29,7 @@ fn github_write_hook() -> crate::mitm_hook::MitmHookConfig { ..crate::mitm_hook::MitmHookMatchConfig::default() }, actions: crate::mitm_hook::MitmHookActionsConfig { + body_secret_substitution: None, aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig { @@ -572,6 +573,7 @@ fn request_credentials_apply_hook_after_brokerage() { let mut secret = HeaderValue::from_static("Bearer secret-token"); secret.set_sensitive(true); let actions = crate::mitm_hook::MitmHookActions { + body_secret_substitution: None, aws_sigv4: None, strip_request_headers: vec![HeaderName::from_static("authorization")], inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader { diff --git a/codex-rs/network-proxy/src/secret_substitution.rs b/codex-rs/network-proxy/src/secret_substitution.rs new file mode 100644 index 000000000000..ff8082095210 --- /dev/null +++ b/codex-rs/network-proxy/src/secret_substitution.rs @@ -0,0 +1,264 @@ +//! Bounded byte-exact secret substitution after destination authorization, before signing. +//! Placeholder bindings and resolved secret values are private to the trusted proxy. + +use crate::aws_sigv4::ProxySecretSource; +use anyhow::Result; +use anyhow::anyhow; +use codex_utils_absolute_path::AbsolutePathBuf; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_http::Body; +use rama_http::HeaderMap; +use rama_http::HeaderValue; +use rama_http::Request; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::time::Duration; + +// Bound the worst-case number of bytes compared by the placeholder window scans. +const MAX_SUBSTITUTION_SEARCH_BYTES: usize = 256 * 1024 * 1024; + +#[derive(Clone, Copy, Debug)] +struct OriginalBodyHeadersSupported(bool); + +pub(crate) fn preserve_body_substitution_headers(req: &mut Request) { + if req + .extensions() + .get::() + .is_none() + { + let supported = body_substitution_headers_supported(req.headers()); + req.extensions_mut() + .insert(OriginalBodyHeadersSupported(supported)); + } +} + +fn body_substitution_headers_supported(headers: &HeaderMap) -> bool { + let identity_encoding = headers.get_all("content-encoding").iter().all(|value| { + value.to_str().is_ok_and(|value| { + value + .split(',') + .all(|coding| coding.trim().eq_ignore_ascii_case("identity")) + }) + }); + identity_encoding + && crate::body_budget::supported_transfer_framing(headers) + && !crate::body_budget::is_amazon_event_stream(headers) + && !headers.contains_key("trailer") + && !headers.contains_key("x-amz-trailer") + && !headers.contains_key("x-amz-decoded-content-length") + && !headers.keys().any(|name| { + matches!( + name.as_str(), + "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "x-amz-content-sha256" + ) || name.as_str().starts_with("x-amz-checksum-") + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BodySecretReplacement { + pub placeholder: String, + pub secret: ProxySecretSource, +} + +/// Exact opaque placeholders scoped by their enclosing host/method/path MITM hook. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BodySecretSubstitutionConfig { + pub replacements: Vec, + #[serde(default = "https_port")] + pub port: u16, + #[serde(default = "default_max_body_bytes")] + pub max_body_bytes: usize, +} + +pub(crate) fn https_port() -> u16 { + 443 +} +pub(crate) fn default_max_body_bytes() -> usize { + 8 * 1024 * 1024 +} + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct BodySecretSubstitution { + config: BodySecretSubstitutionConfig, + secrets: Vec>, +} + +impl std::fmt::Debug for BodySecretSubstitution { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BodySecretSubstitution") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl BodySecretSubstitutionConfig { + pub(crate) fn validate(&self) -> Result<()> { + if self.port == 0 + || self.max_body_bytes > 16 * 1024 * 1024 + || self.replacements.is_empty() + || self.replacements.len() > 32 + { + return Err(anyhow!( + "invalid body substitution port, bindings, or limit (maximum 16 MiB)" + )); + } + for (index, replacement) in self.replacements.iter().enumerate() { + if !(16..=256).contains(&replacement.placeholder.len()) + || !replacement.placeholder.is_ascii() + { + return Err(anyhow!( + "body placeholders must be opaque ASCII markers of 16 to 256 bytes" + )); + } + replacement.secret.validate()?; + if self.replacements[..index].iter().any(|previous| { + previous.placeholder.contains(&replacement.placeholder) + || replacement.placeholder.contains(&previous.placeholder) + }) { + return Err(anyhow!("body substitution bindings overlap")); + } + } + Ok(()) + } + + pub(crate) fn compile( + &self, + env: &impl Fn(&str) -> Option, + file: &impl Fn(&AbsolutePathBuf) -> Result, + ) -> Result { + self.validate()?; + let secrets = self + .replacements + .iter() + .map(|replacement| { + let secret = replacement.secret.resolve(env, file)?; + if secret.len() > self.max_body_bytes + || self + .replacements + .iter() + .any(|binding| secret.contains(&binding.placeholder)) + { + return Err(anyhow!( + "body substitution secret exceeds limit or contains a placeholder" + )); + } + Ok(secret.into_bytes()) + }) + .collect::>>()?; + Ok(BodySecretSubstitution { + config: self.clone(), + secrets, + }) + } +} + +impl BodySecretSubstitution { + pub(crate) async fn apply(&self, mut req: Request) -> Result { + if req.uri().scheme_str() != Some("https") + || req.uri().port_u16().unwrap_or(443) != self.config.port + { + return Err(anyhow!( + "body substitution requires its configured HTTPS port" + )); + } + if req + .extensions() + .get::() + .is_some_and(|original| !original.0) + || !body_substitution_headers_supported(req.headers()) + { + return Err(anyhow!( + "body substitution does not support encoded, streaming-signed, or integrity-protected payloads" + )); + } + let _permit = crate::body_budget::reserve(&mut req).await?; + let (mut parts, body) = req.into_parts(); + let (body, trailers) = tokio::time::timeout( + Duration::from_secs(30), + crate::body_budget::collect_body(body, self.config.max_body_bytes), + ) + .await + .map_err(|_| anyhow!("body substitution read timed out"))? + .map_err(|_| anyhow!("body substitution exceeds limit or cannot read payload"))?; + if trailers.is_some() { + return Err(anyhow!( + "body substitution does not support request trailers" + )); + } + if !crate::body_budget::content_length_matches(&parts.headers, body.len()) { + return Err(anyhow!( + "body substitution content length does not match the request body" + )); + } + // Charge every window for its full length before searching, including failed matches. + let search_bytes = self + .config + .replacements + .iter() + .try_fold(0usize, |work, binding| { + let windows = body.len().saturating_sub(binding.placeholder.len() - 1); + work.checked_add(windows.checked_mul(binding.placeholder.len())?) + }); + if search_bytes.is_none_or(|work| work > MAX_SUBSTITUTION_SEARCH_BYTES) { + return Err(anyhow!("body substitution search exceeds work limit")); + } + // Search the original bytes only. Replacements cannot recursively expand other bindings. + let mut matches = Vec::new(); + for (index, binding) in self.config.replacements.iter().enumerate() { + for (offset, window) in body.windows(binding.placeholder.len()).enumerate() { + if window == binding.placeholder.as_bytes() { + if matches.len() >= 65_536 { + return Err(anyhow!("too many body substitution occurrences")); + } + matches.push((offset, offset + binding.placeholder.len(), index)); + } + } + } + matches.sort_unstable(); + let mut cursor = 0; + let mut size = body.len(); + for &(start, end, index) in &matches { + if start < cursor { + return Err(anyhow!("body substitution matches overlap")); + } + size = size + .checked_sub(end - start) + .and_then(|size| size.checked_add(self.secrets[index].len())) + .ok_or_else(|| anyhow!("expanded body substitution exceeds limit"))?; + cursor = end; + } + if size > self.config.max_body_bytes { + return Err(anyhow!("expanded body substitution exceeds limit")); + } + let mut output = Vec::with_capacity(size); + cursor = 0; + for (start, end, index) in matches { + output.extend_from_slice(&body[cursor..start]); + output.extend_from_slice(&self.secrets[index]); + cursor = end; + } + output.extend_from_slice(&body[cursor..]); + parts.headers.remove("transfer-encoding"); + parts.headers.remove("trailer"); + parts.headers.insert( + "content-length", + HeaderValue::from_str(&output.len().to_string())?, + ); + Ok(crate::body_budget::retain(Request::from_parts( + parts, + Body::from(output), + ))) + } +} + +#[cfg(test)] +#[path = "secret_substitution_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/secret_substitution_tests.rs b/codex-rs/network-proxy/src/secret_substitution_tests.rs new file mode 100644 index 000000000000..8d432105bc9d --- /dev/null +++ b/codex-rs/network-proxy/src/secret_substitution_tests.rs @@ -0,0 +1,261 @@ +//! Exact substitution preserves chunk boundaries, authorization scope, and signed wire bytes. + +use super::*; +use pretty_assertions::assert_eq; +use rama_core::bytes::Bytes; +use rama_core::futures::stream; +use rama_http::body::util::BodyExt; + +const MARKER: &str = "codex-secret-0123456789abcdef"; + +fn config() -> BodySecretSubstitutionConfig { + BodySecretSubstitutionConfig { + replacements: vec![BodySecretReplacement { + placeholder: MARKER.into(), + secret: ProxySecretSource::EnvVar("SECRET".into()), + }], + port: 443, + max_body_bytes: 1024, + } +} + +fn compile(config: BodySecretSubstitutionConfig, secret: &str) -> BodySecretSubstitution { + config + .compile(&|_| Some(secret.into()), &|_| { + panic!("unexpected file read") + }) + .unwrap() +} + +#[tokio::test] +async fn substitutes_split_markers_and_updates_framing() { + let body = Body::from_stream(stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"before:codex-secret-0123")), + Ok(Bytes::from_static(b"456789abcdef:after")), + ])); + let request = Request::builder() + .method("PUT") + .uri("https://bucket.s3.amazonaws.com/object") + .header("transfer-encoding", "chunked") + .header("content-encoding", "Identity, identity") + .header("content-encoding", "IDENTITY") + .body(body) + .unwrap(); + let substituted = compile(config(), "actual-secret") + .apply(request) + .await + .unwrap(); + assert_eq!(substituted.headers()["content-length"], "26"); + assert!(!substituted.headers().contains_key("transfer-encoding")); + let payload = b"before:actual-secret:after"; + assert_eq!( + substituted + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .as_ref(), + payload + ); +} + +#[tokio::test] +async fn scope_overflow_encoding_trailers_and_ambiguous_matches_fail_closed() { + let mut small = config(); + small.max_body_bytes = 40; + let transform = compile(small, "long-replacement-value"); + for (uri, body) in [ + ("http://example.com/", Body::from(MARKER)), + ("https://example.com:444/", Body::from(MARKER)), + ("https://example.com/", Body::from(vec![b'x'; 41])), + ( + "https://example.com/", + Body::empty().with_trailer_headers(rama_http::HeaderMap::new()), + ), + ] { + let req = Request::builder().uri(uri).body(body).unwrap(); + assert!(transform.apply(req).await.is_err()); + } + // Cover expansion and contraction in both orders at the final byte limit. + let expanding_marker = "0123456789abcdef"; + let shrinking_marker = "fedcba9876543210"; + let mut mixed = config(); + mixed.max_body_bytes = 32; + mixed.replacements[0].placeholder = expanding_marker.into(); + mixed.replacements.push(BodySecretReplacement { + placeholder: shrinking_marker.into(), + secret: ProxySecretSource::EnvVar("SHRINKING".into()), + }); + for expanding_secret in ["x".repeat(30), "x".repeat(32)] { + let transform = mixed + .compile( + &|name| match name { + "SECRET" => Some(expanding_secret.clone()), + "SHRINKING" => Some("y".into()), + _ => None, + }, + &|_| panic!("unexpected file read"), + ) + .unwrap(); + for (body, expected) in [ + ( + format!("{expanding_marker}{shrinking_marker}"), + format!("{expanding_secret}y"), + ), + ( + format!("{shrinking_marker}{expanding_marker}"), + format!("y{expanding_secret}"), + ), + ] { + let result = transform + .apply( + Request::builder() + .uri("https://example.com/") + .header("content-length", "32") + .body(Body::from(body)) + .unwrap(), + ) + .await; + if expected.len() > mixed.max_body_bytes { + assert_eq!( + result.unwrap_err().to_string(), + "expanded body substitution exceeds limit" + ); + continue; + } + let substituted = result.unwrap(); + assert_eq!(substituted.headers()["content-length"], "31"); + assert!(!substituted.headers().contains_key("transfer-encoding")); + assert_eq!( + substituted + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .as_ref(), + expected.as_bytes() + ); + } + } + + for coding in [None, Some("chunked")] { + let mut req = Request::builder() + .uri("https://example.com/") + .header("content-length", "1"); + if let Some(coding) = coding { + req = req.header("transfer-encoding", coding); + } + assert!( + transform + .apply(req.body(Body::from(MARKER)).unwrap()) + .await + .is_err() + ); + } + + let mut overlaps = config(); + overlaps.replacements[0].placeholder = "0123456789abcdef0".into(); + let transform = compile(overlaps, "replacement"); + assert!( + transform + .apply( + Request::builder() + .uri("https://example.com/") + .body(Body::from("0123456789abcdef0123456789abcdef0")) + .unwrap() + ) + .await + .is_err() + ); +} + +#[test] +fn rejects_duplicate_bindings_recursive_values_and_redacts_secrets() { + let mut duplicate = config(); + duplicate + .replacements + .push(duplicate.replacements[0].clone()); + assert!(duplicate.validate().is_err()); + assert!( + config() + .compile(&|_| Some(MARKER.into()), &|_| panic!("unexpected file")) + .is_err() + ); + let compiled = compile(config(), "do-not-log-this-secret"); + assert!(!format!("{compiled:?}").contains("do-not-log-this-secret")); +} + +#[tokio::test] +async fn rejects_integrity_headers_and_preserves_original_validation() { + let transform = compile(config(), "secret"); + for (name, value) in [ + ( + "content-type", + "Application/Vnd.Amazon.Eventstream; version=1", + ), + ("content-encoding", "gzip"), + ("transfer-encoding", "gzip, chunked"), + ("trailer", "digest"), + ("x-amz-trailer", "x-amz-checksum-sha256"), + ("x-amz-decoded-content-length", "32"), + ("content-md5", "placeholder-digest"), + ("digest", "sha-256=placeholder-digest"), + ("content-digest", "sha-256=:placeholder-digest:"), + ("repr-digest", "sha-256=:placeholder-digest:"), + ("x-amz-content-sha256", "placeholder-digest"), + ("x-amz-checksum-sha256", "placeholder-digest"), + ("x-amz-checksum-crc32", "placeholder-digest"), + ] { + for strip in [false, true] { + let mut request = Request::builder() + .uri("https://example.com/") + .header(name, value) + .body(Body::from(MARKER)) + .unwrap(); + preserve_body_substitution_headers(&mut request); + if strip { + request.headers_mut().remove(name); + preserve_body_substitution_headers(&mut request); + } + assert!( + transform.apply(request).await.is_err(), + "{name}, strip={strip}" + ); + } + } + let mut request = Request::builder() + .uri("https://example.com/") + .body(Body::from(MARKER)) + .unwrap(); + preserve_body_substitution_headers(&mut request); + request + .headers_mut() + .insert("content-encoding", HeaderValue::from_static("gzip")); + assert!(transform.apply(request).await.is_err()); +} + +#[tokio::test] +async fn rejects_excessive_search_work_before_scanning_shared_prefixes() { + let mut bounded = config(); + bounded.max_body_bytes = 1024 * 1024; + bounded.replacements = (0..32) + .map(|index| BodySecretReplacement { + placeholder: format!("{}{:02}", "x".repeat(254), index), + secret: ProxySecretSource::EnvVar("SECRET".into()), + }) + .collect(); + let request = Request::builder() + .uri("https://example.com/") + .body(Body::from(vec![b'x'; bounded.max_body_bytes])) + .unwrap(); + assert_eq!( + compile(bounded, "secret") + .apply(request) + .await + .unwrap_err() + .to_string(), + "body substitution search exceeds work limit" + ); +} From ec513795a6a727b32f906a2aa27f7fc92a996b6f Mon Sep 17 00:00:00 2001 From: smurching-oai Date: Fri, 23 Oct 2026 20:07:16 +0000 Subject: [PATCH 011/287] Integrate config-server policies with startup and authentication (#57175) ## What changed - Use the managed configuration loader in CLI, exec, TUI, and app-server configuration flows so `requirements.toml` can select a config server. - Sign in to the config server when needed during interactive TUI startup and CLI login, reload configuration after CLI sign-in, and clear config-server credentials on CLI logout. - Preserve the app-server's config-server settings and refresh worker across OpenAI login and logout. - Require config-server settings to be applied when loading CLI and doctor configuration. ## Testing Add app-server integration tests using a local HTTPS config server to verify managed provider and instruction settings at startup, preservation across API-key login and logout, and cache reuse after restart and thread resume. GitOrigin-RevId: 27ee81f7943d2290a5c3fcc0ac556579ec63f8b4 --- codex-rs/app-server/src/config_manager.rs | 39 +-- .../tests/suite/v2/config_server_tests.rs | 241 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 2 + codex-rs/cli/src/cloud_config.rs | 6 +- codex-rs/cli/src/doctor.rs | 6 +- codex-rs/cli/src/lib.rs | 2 +- codex-rs/cli/src/login.rs | 86 ++++++- codex-rs/cli/src/main.rs | 4 +- codex-rs/cloud-config/src/bundle_loader.rs | 21 ++ codex-rs/cloud-config/src/lib.rs | 1 + codex-rs/exec/src/lib.rs | 8 +- codex-rs/tui/src/lib.rs | 8 +- codex-rs/tui/src/startup_orchestration.rs | 16 +- 13 files changed, 401 insertions(+), 39 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/config_server_tests.rs diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 658f7f52bc56..83b1f04f67c2 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -1,5 +1,5 @@ use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::chatgpt_config_bundle_loader; +use codex_cloud_config::managed_config_bundle_loader; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStack; use codex_config::LoaderOverrides; @@ -196,23 +196,24 @@ impl ConfigManager { auth_manager: Arc, config: &Config, ) { - let chatgpt_base_url = config.chatgpt_base_url.clone(); - let http_client_factory = config.http_client_factory(); - let endpoint = codex_backend_client::Client::new( - chatgpt_base_url.clone(), - http_client_factory.clone(), - ) - .config_bundle_url(); - let http_client_factory = http_client_factory.with_network_policy( - self.local_network_policy - .policy() - .restrict_to_endpoints(endpoint.parse().into_iter().collect()), - ); - let loader = chatgpt_config_bundle_loader( + // Keep config-server settings and their refresh worker when only + // ChatGPT/API-key credentials change. + if self + .current_managed_config_loader() + .config_server() + .is_some() + { + self.revoke_mcp_ema(); + return; + } + let loader = managed_config_bundle_loader( auth_manager, - chatgpt_base_url, + config.chatgpt_base_url.clone(), self.codex_home.clone(), - http_client_factory, + config + .http_client_factory() + .with_network_policy(self.local_network_policy.policy()), + config.config_layer_stack.requirements_toml(), ); if let Ok(mut guard) = self.cloud_config_bundle.write() { guard.retire_ema_policy(); @@ -225,8 +226,10 @@ impl ConfigManager { pub(crate) fn clear_chatgpt_config_loader(&self) { if let Ok(mut guard) = self.cloud_config_bundle.write() { - guard.retire_ema_policy(); - *guard = CloudConfigBundleLoader::default(); + if guard.config_server().is_none() { + guard.retire_ema_policy(); + *guard = CloudConfigBundleLoader::default(); + } } else { warn!("failed to clear cloud config bundle loader"); } diff --git a/codex-rs/app-server/tests/suite/v2/config_server_tests.rs b/codex-rs/app-server/tests/suite/v2/config_server_tests.rs new file mode 100644 index 000000000000..f6ca0981e8c6 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/config_server_tests.rs @@ -0,0 +1,241 @@ +//! Exercises required HTTPS policy through real app-server startup and session RPCs. + +use anyhow::Result; +use app_test_support::TestAppServer; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_config::test_support::CloudConfigBundleFixture; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; +use tempfile::tempdir; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::rustls; +use tokio_util::task::AbortOnDropHandle; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_server_settings_are_fetched_and_applied_at_startup() -> Result<()> { + skip_if_no_network!(Ok(())); + let setup = ConfigServerSetup::new().await?; + let mut app = setup.start_app().await?; + assert_eq!( + setup.config_server.received_requests().await.unwrap().len(), + 1 + ); + let started = app + .start_thread(ThreadStartParams { + model_provider: Some("openai".into()), + ..Default::default() + }) + .await?; + assert_eq!(started.model_provider, "company"); + setup + .assert_turn_uses_managed_instructions(&mut app, &started.thread.id) + .await?; + app.shutdown_gracefully().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_server_settings_survive_openai_login_and_logout() -> Result<()> { + skip_if_no_network!(Ok(())); + let setup = ConfigServerSetup::new().await?; + let mut app = setup.start_app().await?; + let login = app + .send_login_account_api_key_request("test-api-key") + .await?; + let _: LoginAccountResponse = app.read_response(login).await?; + let logout = app.send_logout_account_request().await?; + let _: LogoutAccountResponse = app.read_response(logout).await?; + let started = app.start_thread(ThreadStartParams::default()).await?; + setup + .assert_turn_uses_managed_instructions(&mut app, &started.thread.id) + .await?; + app.shutdown_gracefully().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_server_settings_are_cached_across_restart_and_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + let setup = ConfigServerSetup::new().await?; + let mut app = setup.start_app().await?; + let started = app.start_thread(ThreadStartParams::default()).await?; + setup + .assert_turn_uses_managed_instructions(&mut app, &started.thread.id) + .await?; + app.shutdown_gracefully().await?; + + let mut app = setup.start_app().await?; + let resumed = app + .send_thread_resume_request(ThreadResumeParams { + thread_id: started.thread.id, + ..Default::default() + }) + .await?; + let resumed: ThreadResumeResponse = app.read_response(resumed).await?; + setup + .assert_turn_uses_managed_instructions(&mut app, &resumed.thread.id) + .await?; + assert_eq!( + setup.config_server.received_requests().await.unwrap().len(), + 1 + ); + app.shutdown_gracefully().await?; + Ok(()) +} + +struct ConfigServerSetup { + home: TempDir, + config_server: MockServer, + inference: MockServer, + ca_path: PathBuf, + _tls: AbortOnDropHandle<()>, +} + +impl ConfigServerSetup { + async fn new() -> Result { + let home = tempdir()?; + let config_server = MockServer::start().await; + let inference = responses::start_mock_server().await; + let requirements = format!( + r#"additional_developer_instructions = 'Company requirements' +model_provider = 'company' + +[model_providers.company] +name = 'Company' +base_url = {:?} +env_key = 'CONFIG_SERVER_TEST_KEY' +wire_api = 'responses' +"#, + inference.uri() + ); + let bundle = CloudConfigBundleFixture::enterprise_config( + "developer_instructions = 'Company defaults'", + ) + .add_enterprise_requirement(requirements) + .into_bundle(); + Mock::given(method("GET")) + .and(path("/team/codex/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(bundle)) + .mount(&config_server) + .await; + + let (origin, ca_path, tls) = https_mock_server(home.path(), &config_server).await?; + let base_url = format!("{origin}/team/"); + std::fs::write( + home.path().join("requirements.toml"), + format!("[config_server]\nbase_url = {base_url:?}\n"), + )?; + std::fs::write( + home.path().join("config.toml"), + "model = 'gpt-5.5'\napproval_policy = 'never'\nsandbox_mode = 'read-only'\n[features]\napps = false\n", + )?; + Ok(Self { + home, + config_server, + inference, + ca_path, + _tls: tls, + }) + } + + async fn start_app(&self) -> Result { + let ca_path = self.ca_path.to_string_lossy(); + let env = [ + ("CODEX_CA_CERTIFICATE", Some(ca_path.as_ref())), + ("CONFIG_SERVER_TEST_KEY", Some("test-key")), + ("NO_PROXY", Some("127.0.0.1,localhost")), + ]; + TestAppServer::builder() + .with_codex_home(self.home.path()) + .with_args(&["--strict-config"]) + .with_env_overrides(&env) + .build_initialized() + .await + } + + async fn assert_turn_uses_managed_instructions( + &self, + app: &mut TestAppServer, + thread_id: &str, + ) -> Result<()> { + let recorded = responses::mount_sse_once( + &self.inference, + responses::sse(vec![ + responses::ev_assistant_message("reply", "Policy applied"), + responses::ev_completed("response"), + ]), + ) + .await; + let completed = app + .start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread_id.into(), + input: vec![UserInput::Text { + text: "Hello".into(), + text_elements: vec![], + }], + ..Default::default() + }) + .await?; + assert_eq!(completed.turn.status, TurnStatus::Completed); + let instructions = recorded + .single_request() + .message_input_texts("developer") + .join("\n"); + assert!(instructions.contains("Company defaults")); + assert!(instructions.contains("Company requirements")); + Ok(()) + } +} + +async fn https_mock_server( + home: &Path, + policy: &MockServer, +) -> Result<(String, PathBuf, AbortOnDropHandle<()>)> { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into(), "127.0.0.1".into()])?; + let ca_path = home.join("policy-ca.pem"); + std::fs::write(&ca_path, cert.cert.pem())?; + let tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_single_cert( + vec![cert.cert.der().clone()], + rustls::pki_types::PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into(), + )?; + let acceptor = TlsAcceptor::from(Arc::new(tls)); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let origin_url = format!("https://{}", listener.local_addr()?); + let origin = *policy.address(); + let tls = AbortOnDropHandle::new(tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let mut stream = acceptor.accept(stream).await?; + let mut upstream = TcpStream::connect(origin).await?; + tokio::io::copy_bidirectional(&mut stream, &mut upstream).await?; + Ok::<_, std::io::Error>(()) + }); + } + })); + Ok((origin_url, ca_path, tls)) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 3276e866809a..aded29d0d9ff 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -38,6 +38,8 @@ mod config_requirements_exec; mod config_requirements_in_app_browser; mod config_requirements_login; mod config_rpc; +#[path = "config_server_tests.rs"] +mod config_server; #[cfg(unix)] #[path = "connection_handling_stdio_tests.rs"] mod connection_handling_stdio; diff --git a/codex-rs/cli/src/cloud_config.rs b/codex-rs/cli/src/cloud_config.rs index c67b34d62194..8a0f1f374b72 100644 --- a/codex-rs/cli/src/cloud_config.rs +++ b/codex-rs/cli/src/cloud_config.rs @@ -1,7 +1,7 @@ use anyhow::Context; use anyhow::Result; use codex_app_server::in_process::EmbeddedNetworkPolicy; -use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; +use codex_cloud_config::managed_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_core::config::Config; @@ -27,6 +27,7 @@ pub async fn load_config( .build() .await .context("failed to load configuration")?; + config.config_layer_stack.ensure_config_server_applied()?; EmbeddedNetworkPolicy::default().activate(&mut config); Ok(config) } @@ -58,11 +59,12 @@ pub async fn config_builder( ) .await .context("failed to load bootstrap configuration")?; - let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( + let cloud_config_bundle = managed_config_bundle_loader_for_storage( network_policy.bind_bootstrap_auth( bootstrap_auth_config(codex_home.as_path(), &bootstrap_config) .context("failed to resolve cloud configuration authentication")?, ), + bootstrap_config.config_layer_stack.requirements_toml(), /*enable_codex_api_key_env*/ false, ) .await diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 7b80c10e67a5..e7814dc56d1c 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -628,7 +628,7 @@ async fn load_config( ..config_overrides_from_interactive(interactive, arg0_paths) }; - crate::cloud_config::config_builder( + let config = crate::cloud_config::config_builder( &root_config_overrides, LoaderOverrides::default(), overrides, @@ -636,7 +636,9 @@ async fn load_config( .await? .build() .await - .context("failed to load Codex config") + .context("failed to load Codex config")?; + config.config_layer_stack.ensure_config_server_applied()?; + Ok(config) } fn config_overrides_from_interactive( diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index f0d447bb295b..cc91f5b82d87 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -18,7 +18,7 @@ pub use login::read_api_key_from_stdin; pub use login::run_login_status; pub use login::run_login_with_access_token; pub use login::run_login_with_api_key; -pub use login::run_login_with_chatgpt; +pub use login::run_login_with_browser; pub use login::run_login_with_device_code; pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 1e194f67cc07..a471e8cd60df 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -172,6 +172,27 @@ pub async fn login_with_chatgpt( server.block_until_done().await } +/// Signs into the config server if needed, then reloads configuration. +async fn sign_in_to_config_server_and_reload_config( + config: &mut Config, + cli_config_overrides: &CliConfigOverrides, +) -> std::io::Result<()> { + let requirements = config.config_layer_stack.requirements_toml(); + let network = codex_app_server::in_process::EmbeddedNetworkPolicy::load( + &codex_config::LoaderOverrides::default(), + ) + .await; + let bootstrap_auth = network.bind_bootstrap_auth(config.auth_config()); + let auth_route_config = bootstrap_auth.auth_route_config.clone(); + codex_cloud_config::ensure_config_server_login(bootstrap_auth, requirements).await?; + *config = crate::cloud_config::load_config(cli_config_overrides, Default::default()) + .await + .map_err(|error| std::io::Error::other(format!("{error:#}")))?; + network.activate(config); + config.application_auth_route_config = Some(auth_route_config); + Ok(()) +} + fn finish_login(result: std::io::Result<()>) -> ! { match result { Ok(_) => { @@ -187,17 +208,28 @@ fn finish_login(result: std::io::Result<()>) -> ! { async fn run_interactive_login( cli_config_overrides: CliConfigOverrides, - login: impl FnOnce(Config) -> F, + chatgpt_login: impl FnOnce(Config) -> F, ) -> ! where F: std::future::Future>, { - let config = load_config_or_exit(cli_config_overrides).await; + let mut config = load_config_or_exit(cli_config_overrides.clone()).await; + if !config + .config_layer_stack + .requirements_toml() + .requires_remote_config() + { + let _login_log_guard = init_login_file_logging(&config); + finish_login(chatgpt_login(config).await); + } + + let result = + sign_in_to_config_server_and_reload_config(&mut config, &cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); - finish_login(login(config).await) + finish_login(result) } -pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { +pub async fn run_login_with_browser(cli_config_overrides: CliConfigOverrides) -> ! { run_interactive_login(cli_config_overrides, |config| async move { tracing::info!("starting browser login flow"); @@ -226,7 +258,7 @@ pub async fn run_login_with_api_key( cli_config_overrides: CliConfigOverrides, api_key: String, ) -> ! { - let config = load_config_or_exit(cli_config_overrides).await; + let config = load_managed_login_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting api key login flow"); @@ -259,7 +291,7 @@ pub async fn run_login_with_access_token( cli_config_overrides: CliConfigOverrides, access_token: String, ) -> ! { - let config = load_config_or_exit(cli_config_overrides).await; + let config = load_managed_login_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting access token login flow"); @@ -521,9 +553,28 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { } } +async fn logout_config_server(config: &Config) -> std::io::Result { + match codex_cloud_config::managed_config_auth_manager( + config.auth_config(), + config.config_layer_stack.requirements_toml(), + )? { + Some(manager) => manager.logout().await, + None => Ok(false), + } +} + pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let auth_route_config = config.auth_route_config(); + let config_server_logout = if config + .config_layer_stack + .requirements_toml() + .requires_remote_config() + { + logout_config_server(&config).await + } else { + Ok(false) + }; // Cleanup is independent of the current feature gate. Retain the grant lock // through account removal so another process cannot commit a late login. @@ -590,7 +641,14 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { false }; - if logged_out || cleared_bedrock_config { + let config_server_logged_out = match config_server_logout { + Ok(removed) => removed, + Err(error) => { + eprintln!("Error logging out: {error}"); + std::process::exit(1); + } + }; + if logged_out || config_server_logged_out || cleared_bedrock_config { eprintln!("Successfully logged out"); } else { eprintln!("Not logged in"); @@ -598,6 +656,20 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { std::process::exit(0); } +async fn load_managed_login_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { + let mut config = load_config_or_exit(cli_config_overrides.clone()).await; + if config + .config_layer_stack + .requirements_toml() + .requires_remote_config() + && let Err(error) = + sign_in_to_config_server_and_reload_config(&mut config, &cli_config_overrides).await + { + finish_login(Err(error)); + } + config +} + async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { let cli_overrides = match cli_config_overrides.parse_overrides() { Ok(v) => v, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f8b752f8db53..8b39fa155c50 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -16,7 +16,7 @@ use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; use codex_cli::run_login_with_access_token; use codex_cli::run_login_with_api_key; -use codex_cli::run_login_with_chatgpt; +use codex_cli::run_login_with_browser; use codex_cli::run_login_with_device_code; use codex_cli::run_logout; use codex_cloud_tasks::Cli as CloudTasksCli; @@ -1699,7 +1699,7 @@ async fn cli_main( let access_token = read_access_token_from_stdin(); run_login_with_access_token(login_cli.config_overrides, access_token).await; } else { - run_login_with_chatgpt(login_cli.config_overrides).await; + run_login_with_browser(login_cli.config_overrides).await; } } } diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 0ca022897b95..6c79a49c54c7 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -25,6 +25,27 @@ pub fn managed_config_auth_manager( crate::config_server_auth_manager(service_config, auth.codex_home, &factory) } +/// Existing grants are left to the loader's normal cache and refresh handling. +pub async fn ensure_config_server_login( + auth: AuthConfig, + requirements: &ConfigRequirementsToml, +) -> std::io::Result<()> { + let Some(manager) = managed_config_auth_manager(auth, requirements)? else { + return Ok(()); + }; + if let Err(error) = manager.authorization_id().await { + if error + .get_ref() + .and_then(|source| source.downcast_ref::()) + != Some(&codex_login::OAuthSessionError::LoginRequired) + { + return Err(error); + } + manager.login().await?; + } + Ok(()) +} + pub fn managed_config_bundle_loader( auth_manager: Arc, chatgpt_base_url: String, diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 41c72cbb8b82..be5edd07ece3 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -16,6 +16,7 @@ mod validation; pub use bundle_loader::chatgpt_config_bundle_loader; pub use bundle_loader::chatgpt_config_bundle_loader_for_storage; pub use bundle_loader::chatgpt_config_bundle_loader_for_storage_without_cache; +pub use bundle_loader::ensure_config_server_login; pub use bundle_loader::managed_config_auth_manager; pub use bundle_loader::managed_config_bundle_loader; pub use bundle_loader::managed_config_bundle_loader_for_storage; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a8761fdfafcf..054e5a1569a1 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -58,7 +58,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; +use codex_cloud_config::managed_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; @@ -407,9 +407,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result CloudConfigBundleLoader::default(), ) .await; - let gate_cloud_config = chatgpt_config_bundle_loader_for_storage( + let gate_cloud_config = managed_config_bundle_loader_for_storage( embedded_network_policy .bind_bootstrap_auth(bootstrap_auth_config(&codex_home, &gate_bootstrap)?), + gate_bootstrap.config_layer_stack.requirements_toml(), /*enable_codex_api_key_env*/ false, ) .await?; @@ -507,8 +508,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result // API keys cannot fetch workspace-managed configuration. Preserve the // existing ChatGPT bootstrap identity even when model requests allow // CODEX_API_KEY. - let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( + let cloud_config_bundle = managed_config_bundle_loader_for_storage( bootstrap_auth_config, + bootstrap_config.config_layer_stack.requirements_toml(), /*enable_codex_api_key_env*/ false, ) .await?; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 113117caf4fd..d16dac65727c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -47,7 +47,7 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; -use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; +use codex_cloud_config::managed_config_bundle_loader_for_storage; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; @@ -1098,11 +1098,12 @@ async fn cloud_config_bundle_for_app_server_target( codex_home: &Path, embedded_network_policy: &codex_app_server_client::EmbeddedNetworkPolicy, ) -> std::io::Result { - chatgpt_config_bundle_loader_for_storage( + managed_config_bundle_loader_for_storage( embedded_network_policy .bind_bootstrap_auth(app_server_target.auth_config_for_cloud_loader( bootstrap_auth_config(codex_home, bootstrap_config)?, )), + bootstrap_config.config_layer_stack.requirements_toml(), /*enable_codex_api_key_env*/ false, ) .await @@ -1415,8 +1416,9 @@ async fn run_ratatui_app( // bundle and rebuild config. This avoids missing newly available cloud-managed // policy due to login status detection edge cases. if show_login_screen && !uses_remote_workspace && !workload_identity_selected { - cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( + cloud_config_bundle = managed_config_bundle_loader_for_storage( embedded_network_policy.bind_bootstrap_auth(initial_config.auth_config()), + initial_config.config_layer_stack.requirements_toml(), /*enable_codex_api_key_env*/ false, ) .await?; diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 27081eb8b009..50045199e322 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -305,6 +305,19 @@ pub(super) async fn run_main_inner( err } })?; + if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { + codex_cloud_config::ensure_config_server_login( + embedded_network_policy.bind_bootstrap_auth(bootstrap_auth_config( + &codex_home, + &presentation.bootstrap_config, + )?), + presentation + .bootstrap_config + .config_layer_stack + .requirements_toml(), + ) + .await?; + } // Keep normal terminal signals available until local configuration is ready. Once raw // mode begins, StartupDraft immediately takes ownership of input. let (initialized_terminal, terminal_restore_guard) = tokio::task::spawn_blocking(|| { @@ -508,10 +521,11 @@ pub(super) async fn run_main_inner( cloud_config_bundle } else { startup_draft - .run_until(chatgpt_config_bundle_loader_for_storage( + .run_until(managed_config_bundle_loader_for_storage( embedded_network_policy.bind_bootstrap_auth( app_server_target.auth_config_for_cloud_loader(config.auth_config()), ), + config.config_layer_stack.requirements_toml(), /*enable_codex_api_key_env*/ false, )) .await?? From 83a9600acbc902ab8b212ab2e5166d7d55a2519d Mon Sep 17 00:00:00 2001 From: ioanbudea-OAI Date: Fri, 23 Oct 2026 20:20:48 +0000 Subject: [PATCH 012/287] Defer Guardian Node REPL output truncation until prompt preparation (#57176) ## What changed Preserve complete Node REPL tool outputs during synchronous transcript collection and apply the configured output token limit during prompt preparation, after full or delta transcript selection. ## Testing Update transcript assertions to verify that oversized Node REPL outputs remain intact during collection and are truncated to the selected token cap when rendered. GitOrigin-RevId: 30ced935c04db456a277f7e58281d45e0e2047dd --- codex-rs/core/src/guardian/prompt.rs | 11 +++++++---- codex-rs/core/src/guardian/tests.rs | 12 ++++++------ codex-rs/guardian-context/src/profile.rs | 5 ++--- codex-rs/guardian-context/src/transcript.rs | 7 ++++++- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index f87b25233ae0..41a1e7429725 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -215,6 +215,7 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( Some(context) => context.turn().config.guardian_transcript_mode, None => session.get_config().await.guardian_transcript_mode, }; + profile.transcript.entry_limits.node_repl_output_tokens = node_repl_result_token_limit; let mut transcript = profile.prepare_transcript(transcript_entries, offset); if transcript_entries.is_empty() { transcript.items.push(Budgeted::required( @@ -233,9 +234,11 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( #[cfg(test)] pub(crate) fn render_guardian_transcript_entries( entries: &[ConversationTranscriptEntry], + node_repl_result_token_limit: usize, ) -> (Vec, Option) { - let mut transcript = - ContextProfile::synchronous().prepare_transcript(entries, /*entry_number_offset*/ 0); + let mut profile = ContextProfile::synchronous(); + profile.transcript.entry_limits.node_repl_output_tokens = node_repl_result_token_limit; + let mut transcript = profile.prepare_transcript(entries, /*entry_number_offset*/ 0); if entries.is_empty() { transcript.items.push(Budgeted::required( codex_guardian_context::TranscriptContent::Text( @@ -267,8 +270,8 @@ pub(crate) fn render_guardian_transcript_entries( /// Keep both tool calls and tool results here. The reviewer often needs the /// agent's exact queried path / arguments as well as the returned evidence to /// decide whether the pending approval is justified. -/// Tool-call and output truncation happen in sync preparation; other caps still apply -/// during collection. The cursor counts every non-empty evidence entry. +/// Per-entry truncation happens in sync preparation after cursor selection. +/// The cursor counts every non-empty evidence entry. pub(super) fn collect_guardian_context( history: &dyn SectionHistory, node_repl_result_token_limit: usize, diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index df88920591aa..20b0320df856 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1186,16 +1186,14 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { kind: ConversationTranscriptEntryKind::NodeReplToolOutput( "tool read_file result".to_string() ), - content: codex_guardian_context::TranscriptContent::Text( - guardian_truncate_text(&oversized_result, token_cap).0 - ), + content: codex_guardian_context::TranscriptContent::Text(oversized_result.clone()), original_bytes: oversized_result.len(), retained_source: None, } ); assert_eq!(entries.len(), 4); assert_eq!( - render_guardian_transcript_entries(&entries), + render_guardian_transcript_entries(&entries, token_cap), ( vec![ "[1] user: check the repo".to_string(), @@ -1829,7 +1827,8 @@ fn build_guardian_transcript_reserves_separate_budget_for_tool_evidence() { })); let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); - let (transcript, omission) = render_guardian_transcript_entries(&entries); + let (transcript, omission) = + render_guardian_transcript_entries(&entries, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); assert!( transcript @@ -1902,7 +1901,8 @@ fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_ ]); let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); - let (transcript, omission) = render_guardian_transcript_entries(&entries); + let (transcript, omission) = + render_guardian_transcript_entries(&entries, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); assert_eq!( transcript[..8], diff --git a/codex-rs/guardian-context/src/profile.rs b/codex-rs/guardian-context/src/profile.rs index b0364d0595b8..df58ff3dbde3 100644 --- a/codex-rs/guardian-context/src/profile.rs +++ b/codex-rs/guardian-context/src/profile.rs @@ -335,9 +335,8 @@ fn prepare_sync_content<'a>( ConversationTranscriptEntryKind::Assistant | ConversationTranscriptEntryKind::ProtectedAssistant | ConversationTranscriptEntryKind::Reasoning => limits.message_tokens, - ConversationTranscriptEntryKind::User - | ConversationTranscriptEntryKind::Developer - | ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { + ConversationTranscriptEntryKind::NodeReplToolOutput(_) => limits.node_repl_output_tokens, + ConversationTranscriptEntryKind::User | ConversationTranscriptEntryKind::Developer => { return std::borrow::Cow::Borrowed(&entry.content); } }; diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index d146be7673ab..56a2ea2a1070 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -2,7 +2,7 @@ //! //! Both Guardian consumers receive the same role and tool-source attribution, //! with generated compaction summaries attributed to the assistant, complete user -//! messages and capped non-user entries, except sync tool and assistant entries prepared later. Resolved context profiles +//! messages and capped non-user entries, with sync entry caps deferred to request preparation. Resolved context profiles //! apply aggregate retention after the host selects its full/delta slice. Tool outputs with a //! call ID retain their generic label when the call is unavailable. Outputs //! without a call ID require an explicit name. @@ -431,6 +431,11 @@ fn collect_transcript_for_target( | ConversationTranscriptEntryKind::ToolOutput(_) => { truncate_text(&text, config.entry_limits.tool_tokens) } + ConversationTranscriptEntryKind::NodeReplToolOutput(_) + if target == crate::ContextTarget::Sync => + { + text + } ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { truncate_text(&text, config.entry_limits.node_repl_output_tokens) } From 3d1001e8652212529810500facf31f63c1527d24 Mon Sep 17 00:00:00 2001 From: lsingh-oai Date: Fri, 23 Oct 2026 20:23:29 +0000 Subject: [PATCH 013/287] Route FedRAMP API keys to government endpoints by default (#57178) ## Why API keys with FedRAMP issuance markers previously used commercial endpoint defaults without a FedRAMP request header. ## What changed - Recognize `sk-proj-fedramp-` and `sk-svcacct-fedramp-` prefixes as routing hints and send `X-OpenAI-Fedramp: true`, preserving the complete key for authentication. - Select `https://gov.api.openai.com/v1` for default OpenAI API routing and `https://gov.chatgpt.com/backend-api/` for default ChatGPT backend requests, including model discovery and analytics. - Preserve explicit endpoint overrides, including an explicitly configured commercial default URL. - Resolve provider routing and authentication from the same credential snapshot, and apply key-dependent routing to realtime WebSocket startup. ## Testing Add regression coverage for prefix recognition, unchanged key storage and authorization, provider credential precedence, endpoint overrides, credential rotation, model discovery, analytics routing, and realtime request preparation. GitOrigin-RevId: a62576ce205c34e067532b8e6c0e431f87a157e2 --- codex-rs/analytics/src/client.rs | 36 +++-- .../analytics/src/client_product_tests.rs | 2 +- codex-rs/analytics/src/client_tests.rs | 51 +++++-- codex-rs/app-server/src/analytics_utils.rs | 2 +- .../app-server/src/in_process_bootstrap.rs | 1 + .../src/in_process_bootstrap_tests.rs | 21 ++- codex-rs/app-server/src/turn_cost_worker.rs | 10 +- codex-rs/cli/src/doctor.rs | 15 +- codex-rs/core/src/config/config_tests.rs | 59 +++++++- codex-rs/core/src/config/mod.rs | 26 +++- codex-rs/core/src/realtime_conversation.rs | 11 ++ .../core/src/realtime_conversation_tests.rs | 99 ++++++++++++ codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/tests/suite/client.rs | 18 ++- codex-rs/ext/history-notes/src/backend.rs | 7 +- codex-rs/ext/image-generation/src/backend.rs | 9 +- codex-rs/ext/web-search/src/tool.rs | 9 +- codex-rs/login/src/auth/api_key.rs | 9 ++ codex-rs/login/src/auth/auth_tests.rs | 103 ++++++++----- codex-rs/login/src/auth/manager.rs | 4 +- codex-rs/login/src/auth/mod.rs | 2 + codex-rs/model-provider/src/auth.rs | 66 +++++++- .../src/bearer_auth_provider.rs | 4 +- .../model-provider/src/chatgpt_base_url.rs | 40 +++++ codex-rs/model-provider/src/lib.rs | 4 + .../model-provider/src/models_endpoint.rs | 121 +++++++++++++-- .../model-provider/src/models_identity.rs | 3 +- .../src/models_identity_tests.rs | 16 +- codex-rs/model-provider/src/provider.rs | 141 +++++++++++++++++- .../model-provider/src/shared_state_tests.rs | 3 +- codex-rs/thread-manager-sample/src/main.rs | 1 + 31 files changed, 747 insertions(+), 148 deletions(-) create mode 100644 codex-rs/login/src/auth/api_key.rs create mode 100644 codex-rs/model-provider/src/chatgpt_base_url.rs diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b0a8705b1bf4..0c54d0167a94 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -65,6 +65,7 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::item_event_to_server_notification; use codex_login::AuthManager; use codex_login::CodexAuth; +use codex_model_provider::ChatgptBaseUrl; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::ThreadId; @@ -116,7 +117,7 @@ pub struct AnalyticsEventsClient { #[derive(Clone, Debug, Eq, PartialEq)] enum AnalyticsEventsDestination { Http { - url: String, + base_url: ChatgptBaseUrl, }, #[cfg(debug_assertions)] CaptureFile { @@ -125,12 +126,15 @@ enum AnalyticsEventsDestination { } impl AnalyticsEventsDestination { - fn from_base_url(base_url: String) -> Self { + fn from_base_url(base_url: ChatgptBaseUrl) -> Self { let capture_file = analytics_capture_file_from_env(); Self::from_base_url_and_capture_file(base_url, capture_file) } - fn from_base_url_and_capture_file(base_url: String, capture_file: Option) -> Self { + fn from_base_url_and_capture_file( + base_url: ChatgptBaseUrl, + capture_file: Option, + ) -> Self { #[cfg(debug_assertions)] if let Some(path) = capture_file { if let Err(err) = crate::analytics_capture::initialize(&path) { @@ -149,9 +153,17 @@ impl AnalyticsEventsDestination { #[cfg(not(debug_assertions))] let _ = capture_file; - let base_url = base_url.trim_end_matches('/'); - Self::Http { - url: format!("{base_url}/codex/analytics-events/events"), + Self::Http { base_url } + } + + fn url_for_auth(&self, auth: &CodexAuth) -> Option { + match self { + Self::Http { base_url } => { + let base_url = base_url.resolve(auth).trim_end_matches('/'); + Some(format!("{base_url}/codex/analytics-events/events")) + } + #[cfg(debug_assertions)] + Self::CaptureFile { .. } => None, } } } @@ -313,10 +325,10 @@ impl AnalyticsEventsClient { pub fn new( auth_manager: Arc, - base_url: String, + base_url: impl Into, analytics_enabled: Option, ) -> Self { - let destination = AnalyticsEventsDestination::from_base_url(base_url); + let destination = AnalyticsEventsDestination::from_base_url(base_url.into()); Self { queue: (analytics_enabled != Some(false)) .then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), destination)), @@ -1057,10 +1069,8 @@ async fn send_track_events_request( return; } - let url = match destination { - AnalyticsEventsDestination::Http { url } => url, - #[cfg(debug_assertions)] - AnalyticsEventsDestination::CaptureFile { .. } => return, + let Some(url) = destination.url_for_auth(auth) else { + return; }; let headers = match codex_model_provider::auth_provider_from_auth(auth).to_auth_headers() { Ok(headers) => headers, @@ -1084,7 +1094,7 @@ async fn send_track_events_request( } }; let mut request = client - .post(url) + .post(&url) .timeout(ANALYTICS_EVENTS_TIMEOUT) .headers(headers) .header("Content-Type", "application/json") diff --git a/codex-rs/analytics/src/client_product_tests.rs b/codex-rs/analytics/src/client_product_tests.rs index 641510cd55fd..39c50f73ce75 100644 --- a/codex-rs/analytics/src/client_product_tests.rs +++ b/codex-rs/analytics/src/client_product_tests.rs @@ -89,7 +89,7 @@ async fn capture_requests( ) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let destination = AnalyticsEventsDestination::Http { - url: format!("http://{}/events", listener.local_addr().unwrap()), + base_url: format!("http://{}", listener.local_addr().unwrap()).into(), }; let (paused_tx, paused_rx) = tokio::sync::oneshot::channel(); let (resume_tx, resume_rx) = tokio::sync::oneshot::channel(); diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index e28a38e503a9..d7d8ff637e6a 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -95,6 +95,7 @@ use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; #[cfg(debug_assertions)] use codex_login::AuthManager; +use codex_model_provider::ChatgptBaseUrl; use codex_protocol::protocol::RealtimeConversationVersion; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -308,7 +309,7 @@ fn client_with_receiver() -> ( fn analytics_destination_uses_explicit_capture_file() { let capture_path = unique_capture_path("destination"); let destination = AnalyticsEventsDestination::from_base_url_and_capture_file( - "https://chatgpt.com/backend-api/".to_string(), + "https://chatgpt.com/backend-api/".into(), Some(capture_path.clone()), ); @@ -337,31 +338,53 @@ fn analytics_destination_uses_explicit_capture_file() { #[test] fn analytics_destination_uses_http_without_capture_file() { - let destination = AnalyticsEventsDestination::from_base_url_and_capture_file( - "https://chatgpt.com/backend-api/".to_string(), - /*capture_file*/ None, - ); - - assert_eq!( - destination, - AnalyticsEventsDestination::Http { - url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string() + let commercial_url = "https://chatgpt.com/backend-api/codex/analytics-events/events"; + let gov_url = "https://gov.chatgpt.com/backend-api/codex/analytics-events/events"; + for (base_url, expected_urls) in [ + ( + ChatgptBaseUrl::Default, + [commercial_url, gov_url, commercial_url], + ), + ( + ChatgptBaseUrl::from("https://chatgpt.com/backend-api/"), + [commercial_url; 3], + ), + ( + ChatgptBaseUrl::from("https://proxy.example/backend-api/"), + ["https://proxy.example/backend-api/codex/analytics-events/events"; 3], + ), + ] { + let destination = AnalyticsEventsDestination::from_base_url_and_capture_file( + base_url.clone(), + /*capture_file*/ None, + ); + assert_eq!(destination, AnalyticsEventsDestination::Http { base_url }); + + for (api_key, expected_url) in ["sk-test", "sk-proj-fedramp-test", "sk-test"] + .into_iter() + .zip(expected_urls) + { + let auth = codex_login::CodexAuth::from_api_key(api_key); + assert_eq!( + destination.url_for_auth(&auth).as_deref(), + Some(expected_url) + ); } - ); + } } #[test] #[cfg(not(debug_assertions))] fn analytics_destination_ignores_capture_file_in_release() { let destination = AnalyticsEventsDestination::from_base_url_and_capture_file( - "https://chatgpt.com/backend-api/".to_string(), + "https://chatgpt.com/backend-api/".into(), Some(std::path::PathBuf::from("ignored.jsonl")), ); assert_eq!( destination, AnalyticsEventsDestination::Http { - url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string() + base_url: "https://chatgpt.com/backend-api/".into() } ); } @@ -1172,7 +1195,7 @@ async fn account_switch_between_batches_does_not_send_old_credentials() { assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let destination = AnalyticsEventsDestination::Http { - url: format!("http://{}/events", listener.local_addr().unwrap()), + base_url: format!("http://{}", listener.local_addr().unwrap()).into(), }; let requests = Arc::new(Mutex::new(Vec::new())); let captured = Arc::clone(&requests); diff --git a/codex-rs/app-server/src/analytics_utils.rs b/codex-rs/app-server/src/analytics_utils.rs index 24ed12d2ad3c..2766fb759cfe 100644 --- a/codex-rs/app-server/src/analytics_utils.rs +++ b/codex-rs/app-server/src/analytics_utils.rs @@ -10,7 +10,7 @@ pub(crate) fn analytics_events_client_from_config( ) -> AnalyticsEventsClient { AnalyticsEventsClient::new( auth_manager, - config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.chatgpt_backend_url(), config.analytics_enabled, ) } diff --git a/codex-rs/app-server/src/in_process_bootstrap.rs b/codex-rs/app-server/src/in_process_bootstrap.rs index ece3737a5690..842346269276 100644 --- a/codex-rs/app-server/src/in_process_bootstrap.rs +++ b/codex-rs/app-server/src/in_process_bootstrap.rs @@ -159,6 +159,7 @@ pub(super) async fn configure( }; let config = Arc::make_mut(config); config.chatgpt_base_url = policy_config.chatgpt_base_url; + config.chatgpt_base_url_is_explicit = policy_config.chatgpt_base_url_is_explicit; config.enforce_residency = policy_config.enforce_residency; config.application_network_policy = policy_config.application_network_policy; config.application_auth_route_config = policy_config.application_auth_route_config; diff --git a/codex-rs/app-server/src/in_process_bootstrap_tests.rs b/codex-rs/app-server/src/in_process_bootstrap_tests.rs index c6aa41d29709..9d7ec83bba73 100644 --- a/codex-rs/app-server/src/in_process_bootstrap_tests.rs +++ b/codex-rs/app-server/src/in_process_bootstrap_tests.rs @@ -23,6 +23,7 @@ use codex_login::CodexAuth; use codex_login::ExternalAuth; use codex_login::ExternalAuthFuture; use codex_login::ExternalAuthRefreshContext; +use codex_model_provider::DEFAULT_CHATGPT_BASE_URL; use codex_protocol::config_types::TrustLevel; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -157,7 +158,7 @@ async fn startup_reloads_the_callers_selected_project() -> anyhow::Result<()> { let user_config = home.join("config.toml"); let trust = std::fs::read_to_string(&user_config)?; std::fs::write( - user_config, + &user_config, format!("chatgpt_base_url = '{selected_url}'\n{trust}"), )?; let project_config = selected.join(".codex/config.toml"); @@ -197,6 +198,24 @@ async fn startup_reloads_the_callers_selected_project() -> anyhow::Result<()> { ); assert_eq!(config.chatgpt_base_url, selected_url); + let marked = CodexAuth::from_api_key("sk-proj-fedramp-test-key"); + for (configured_url, expected_url) in [ + (None, "https://gov.chatgpt.com/backend-api/"), + (Some(DEFAULT_CHATGPT_BASE_URL), DEFAULT_CHATGPT_BASE_URL), + ] { + let setting = configured_url + .map(|url| format!("chatgpt_base_url = '{url}'\n")) + .unwrap_or_default(); + std::fs::write(&user_config, format!("{setting}{trust}"))?; + let _auth = configure( + &manager, + &mut config, + /*enable_codex_api_key_env*/ false, + ) + .await?; + assert_eq!(config.chatgpt_backend_url().resolve(&marked), expected_url); + } + std::fs::write(&project_config, "[malformed")?; let error = configure( &manager, diff --git a/codex-rs/app-server/src/turn_cost_worker.rs b/codex-rs/app-server/src/turn_cost_worker.rs index c36108b4d583..1aa2c60c3d6e 100644 --- a/codex-rs/app-server/src/turn_cost_worker.rs +++ b/codex-rs/app-server/src/turn_cost_worker.rs @@ -424,7 +424,7 @@ impl WorkerRuntime { .to_api_provider(Some(AuthMode::ApiKey)) .map_err(|error| RequestError::Other(error.into()))?; let client = BackendClient::from_auth( - self.config.chatgpt_base_url.clone(), + self.config.chatgpt_backend_url().resolve(&auth), &auth, http_client_factory, ); @@ -448,12 +448,8 @@ impl WorkerRuntime { return Ok(None); } } - let provider = model_provider - .api_provider() - .await - .map_err(|error| RequestError::Other(error.into()))?; - let auth = model_provider - .api_auth() + let (provider, auth) = model_provider + .api_provider_and_auth() .await .map_err(|error| RequestError::Other(error.into()))?; let endpoint = provider.url_for_path("analytics/codex/turn-costs"); diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index e7814dc56d1c..8f2f915481b2 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2318,8 +2318,8 @@ async fn websocket_reachability_check( auth.as_ref().map(auth_mode_name).unwrap_or("none") )); - let api_provider = match runtime_provider.api_provider().await { - Ok(api_provider) => api_provider, + let (api_provider, api_auth) = match runtime_provider.api_provider_and_auth().await { + Ok(setup) => setup, Err(err) => { return websocket_probe_warning( "Responses WebSocket provider setup failed", @@ -2346,17 +2346,6 @@ async fn websocket_reachability_check( } } - let api_auth = match runtime_provider.api_auth().await { - Ok(api_auth) => api_auth, - Err(err) => { - return websocket_probe_warning( - "Responses WebSocket auth could not be resolved", - details, - format!("auth resolution failed: {err}"), - ); - } - }; - let mut extra_headers = HeaderMap::new(); extra_headers.insert( OPENAI_BETA_HEADER, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index aa9c3831ed68..b79f113fad11 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -74,6 +74,7 @@ use codex_config::types::WindowsToml; use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_features::FeaturesToml; +use codex_login::CodexAuth; use codex_login::default_client::RESIDENCY_HEADER_NAME; use codex_login::test_support::auth_manager_from_optional_auth; use codex_model_provider::ProviderCapabilities; @@ -6547,11 +6548,14 @@ async fn config_resolves_explicit_keyring_auth_store_mode() -> std::io::Result<( Ok(()) } +#[test_case::test_case("https://managed.example/backend-api/"; "custom")] +#[test_case::test_case(DEFAULT_CHATGPT_BASE_URL; "explicit_default")] #[tokio::test] -async fn config_applies_managed_auth_store_and_chatgpt_base_url() -> std::io::Result<()> { +async fn config_applies_managed_auth_store_and_chatgpt_base_url( + managed_url: &str, +) -> std::io::Result<()> { let codex_home = TempDir::new()?; let managed_store = AuthCredentialsStoreMode::Keyring; - let managed_url = "https://managed.example/backend-api/"; let requirements = codex_config::ConfigRequirements { cli_auth_credentials_store: Some(Sourced::new(managed_store, RequirementSource::Unknown)), chatgpt_base_url: Some(Sourced::new( @@ -6584,6 +6588,13 @@ async fn config_applies_managed_auth_store_and_chatgpt_base_url() -> std::io::Re assert_eq!(config.cli_auth_credentials_store_mode, managed_store); assert_eq!(config.chatgpt_base_url, managed_url); + assert!(config.chatgpt_base_url_is_explicit); + assert_eq!( + config + .chatgpt_backend_url() + .resolve(&CodexAuth::from_api_key("sk-proj-fedramp-test-key")), + managed_url + ); assert!(config.startup_warnings.iter().any(|warning| { warning.contains("Configured value for `cli_auth_credentials_store` is overridden") })); @@ -6594,6 +6605,50 @@ async fn config_applies_managed_auth_store_and_chatgpt_base_url() -> std::io::Re Ok(()) } +#[tokio::test] +async fn chatgpt_backend_url_defaults_follow_captured_auth() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let legacy = CodexAuth::from_api_key("legacy-key"); + let marked = CodexAuth::from_api_key("sk-proj-fedramp-test-key"); + let custom_url = "https://custom.example/backend-api/"; + for configured_url in [None, Some(custom_url), Some(DEFAULT_CHATGPT_BASE_URL)] { + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + configured_url + .map(|url| format!("chatgpt_base_url = '{url}'\n")) + .unwrap_or_default(), + )?; + let mut config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + let raw_url = configured_url.unwrap_or(DEFAULT_CHATGPT_BASE_URL); + let raw_auth_config = config.auth_config(); + assert_eq!( + config.chatgpt_base_url_is_explicit, + configured_url.is_some() + ); + for (auth, default_url) in [ + (&legacy, DEFAULT_CHATGPT_BASE_URL), + (&marked, "https://gov.chatgpt.com/backend-api/"), + (&legacy, DEFAULT_CHATGPT_BASE_URL), + ] { + assert_eq!( + config.chatgpt_backend_url().resolve(auth), + configured_url.unwrap_or(default_url) + ); + assert_eq!(config.chatgpt_base_url, raw_url); + assert_eq!(config.auth_config(), raw_auth_config); + } + if configured_url.is_none() { + config.chatgpt_base_url = custom_url.to_owned(); + assert_eq!(config.chatgpt_backend_url().resolve(&marked), custom_url); + } + } + Ok(()) +} + #[tokio::test] async fn project_cannot_be_the_only_xaa_opt_in_source() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 171bcc3ba29c..810df098ae0b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,6 +89,8 @@ use codex_mcp::McpPluginAttribution; use codex_mcp::McpProtocolMode; use codex_mcp::McpServerRegistration; use codex_mcp::ResolvedMcpCatalog; +use codex_model_provider::ChatgptBaseUrl; +use codex_model_provider::DEFAULT_CHATGPT_BASE_URL; use codex_model_provider::ProviderCapabilities; use codex_model_provider_info::GCP_PROVIDER_ID; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; @@ -1066,6 +1068,9 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Whether configuration or managed requirements explicitly selected the ChatGPT base URL. + pub chatgpt_base_url_is_explicit: bool, + /// Whether Codex-owned clients should respect host system proxy settings. pub respect_system_proxy: bool, @@ -1758,6 +1763,15 @@ impl Config { } } + /// Returns backend routing that can resolve defaults against the request's auth snapshot. + pub fn chatgpt_backend_url(&self) -> ChatgptBaseUrl { + if !self.chatgpt_base_url_is_explicit && self.chatgpt_base_url == DEFAULT_CHATGPT_BASE_URL { + ChatgptBaseUrl::Default + } else { + ChatgptBaseUrl::Configured(self.chatgpt_base_url.clone()) + } + } + /// Returns auth routing resolved from the effective feature configuration. pub fn auth_route_config(&self) -> AuthRouteConfig { self.application_auth_route_config @@ -3395,6 +3409,15 @@ impl Config { config_layer_stack.requirements(), &mut startup_warnings, ); + // Packaged defaults supply a URL even when no override was configured. + let chatgpt_base_url_is_explicit = + config_layer_stack.requirements().chatgpt_base_url.is_some() + || config_layer_stack + .layers_high_to_low() + .find(|layer| layer.config.get("chatgpt_base_url").is_some()) + .map_or(cfg.chatgpt_base_url.is_some(), |layer| { + !matches!(layer.name, ConfigLayerSource::PackagedDefaults { .. }) + }); // Destructure every field to ensure ConfigRequirements additions are // either applied above or handled while constructing the final Config. let ConfigRequirements { @@ -4631,9 +4654,10 @@ impl Config { model_reasoning_summary: cfg.model_reasoning_summary, model_catalog, model_verbosity: cfg.model_verbosity, + chatgpt_base_url_is_explicit, chatgpt_base_url: cfg .chatgpt_base_url - .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + .unwrap_or_else(|| DEFAULT_CHATGPT_BASE_URL.to_string()), respect_system_proxy, apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), responses_api_metadata: cfg.responses_api_metadata.unwrap_or_default(), diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 4156ac88d103..5cca6eb0b555 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -33,9 +33,11 @@ use codex_config::config_toml::RealtimeWsMode; use codex_config::config_toml::RealtimeWsVersion; use codex_http_client::HttpClientFactory; use codex_login::CodexAuth; +use codex_login::auth::is_fedramp_api_key; use codex_login::default_client::add_originator_header; use codex_login::default_client::default_headers; use codex_login::read_openai_api_key_from_env; +use codex_model_provider::resolve_api_provider; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::auth::AuthMode; use codex_protocol::error::CodexErr; @@ -1442,6 +1444,12 @@ async fn prepare_realtime_start( let mut extra_headers = match transport { ConversationStartTransport::Websocket => { let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?; + if realtime_sideband_base_url.is_none() { + api_provider = resolve_api_provider( + &provider, + Some(&CodexAuth::from_api_key(&realtime_api_key)), + )?; + } realtime_request_headers( requested_realtime_session_id.as_deref(), params.voice_session_id.as_deref(), @@ -1990,6 +1998,9 @@ fn realtime_request_headers( CodexErr::InvalidRequest(format!("invalid realtime api key header: {err}")) })?; headers.insert(AUTHORIZATION, auth_value); + if is_fedramp_api_key(api_key) { + headers.insert("X-OpenAI-Fedramp", HeaderValue::from_static("true")); + } } add_originator_header(&mut headers, originator); diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs index 9d827989aae6..985fc171499c 100644 --- a/codex-rs/core/src/realtime_conversation_tests.rs +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -8,6 +8,7 @@ use super::RealtimeSessionKind; use super::RealtimeStreamedItem; use super::classify_realtime_input_error; use super::classify_realtime_input_error_with_pending; +use super::prepare_realtime_start; use super::realtime_delegation_from_handoff; use super::realtime_request_headers; use super::realtime_text_from_handoff_request; @@ -16,11 +17,16 @@ use crate::context::RealtimeDelegationSource; use async_channel::bounded; use codex_api::ApiError; use codex_api::RealtimeEventParser; +use codex_login::CodexAuth; +use codex_model_provider_info::ModelProviderInfo; use codex_protocol::models::MessagePhase; use codex_protocol::protocol::CodexResponseHandoffMode; +use codex_protocol::protocol::ConversationStartParams; use codex_protocol::protocol::ConversationTextParams; use codex_protocol::protocol::ConversationTextRole; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeHandoffRequested; +use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RealtimeTranscriptEntry; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -29,6 +35,97 @@ use std::time::Instant; use test_case::test_case; use tokio::sync::Mutex; +#[tokio::test] +async fn prepared_realtime_routing_follows_selected_api_key_and_explicit_overrides() { + for (api_key, base_url, realtime_base_url, expected_url, expected_fedramp_header) in [ + ("legacy-key", None, None, "https://api.openai.com/v1", None), + ( + "sk-proj-fedramp-synthetic-test-key", + None, + None, + "https://gov.api.openai.com/v1", + Some("true"), + ), + ( + "sk-svcacct-fedramp-synthetic-test-key", + None, + None, + "https://gov.api.openai.com/v1", + Some("true"), + ), + ( + "sk-proj-fedramp-synthetic-test-key", + Some("https://proxy.example/v1"), + None, + "https://proxy.example/v1", + Some("true"), + ), + ( + "sk-svcacct-fedramp-synthetic-test-key", + Some("https://proxy.example/v1"), + Some("wss://realtime.example/v1"), + "wss://realtime.example/v1", + Some("true"), + ), + ] { + let (sess, _turn, _events) = + crate::session::tests::make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("sk-proj-fedramp-ambient-test-key"), + Vec::new(), + |config| { + config.model_provider = ModelProviderInfo { + experimental_bearer_token: Some(api_key.into()), + ..ModelProviderInfo::create_openai_provider(base_url.map(str::to_string)) + }; + config.experimental_realtime_ws_base_url = + realtime_base_url.map(str::to_string); + }, + ) + .await; + let prepared = prepare_realtime_start( + &sess, + ConversationStartParams { + client_managed_handoffs: false, + delegation_ack_filler: None, + flush_transcript_tail_on_session_end: false, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_mode: CodexResponseHandoffMode::Thinking, + backend_reasoning_status: false, + codex_response_handoff_channel_prefixes: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: false, + initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, + prompt: None, + realtime_session_id: None, + voice_session_id: None, + transport: None, + version: Some(RealtimeConversationVersion::V1), + voice: None, + }, + ) + .await + .expect("realtime startup should prepare without connecting"); + assert_eq!(prepared.api_provider.base_url, expected_url); + let headers = prepared.extra_headers.expect("realtime request headers"); + assert_eq!( + headers[http::header::AUTHORIZATION] + .to_str() + .expect("authorization should be ASCII"), + format!("Bearer {api_key}") + ); + assert_eq!( + headers + .get("X-OpenAI-Fedramp") + .and_then(|value| value.to_str().ok()), + expected_fedramp_header + ); + } +} + /// Hold old input cleanup open while a successor tries to claim the connection. #[tokio::test] async fn natural_close_finishes_before_successor_and_ignores_old_identity() { @@ -508,6 +605,8 @@ fn realtime_headers_keep_realtime_and_voice_session_ids_distinct() { .expect("headers") .expect("headers"); + assert!(!headers.contains_key(http::header::AUTHORIZATION)); + assert!(!headers.contains_key("X-OpenAI-Fedramp")); assert_eq!( headers .get("x-session-id") diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9c415e602096..59c926b9f8de 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1691,7 +1691,7 @@ impl Session { analytics_events_client.unwrap_or_else(|| { AnalyticsEventsClient::new( Arc::clone(&auth_manager), - config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.chatgpt_backend_url(), config.analytics_enabled, ) }) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d842f0fe5cd2..624d3b9b1b11 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1483,8 +1483,14 @@ async fn resume_replays_image_tool_outputs_with_detail() { ); } +#[test_case::test_case("Test API Key", None; "legacy_key")] +#[test_case::test_case("sk-proj-fedramp-synthetic-test-key", Some("true"); "fedramp_project_key")] +#[test_case::test_case("sk-svcacct-fedramp-synthetic-test-key", Some("true"); "fedramp_service_account_key")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn includes_session_id_thread_id_and_model_headers_in_request() { +async fn includes_session_id_thread_id_and_model_headers_in_request( + api_key: &str, + expected_fedramp_header: Option<&str>, +) { skip_if_no_network!(); // Mock server @@ -1496,7 +1502,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { ) .await; - let mut builder = test_codex().with_auth(CodexAuth::from_api_key("Test API Key")); + let mut builder = test_codex().with_auth(CodexAuth::from_api_key(api_key)); let test = builder .build(&server) .await @@ -1511,7 +1517,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { text_elements: Vec::new(), }])) .await - .unwrap(); + .expect("start test turn"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1534,7 +1540,11 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { assert_eq!(request_session_id, session_id_string.as_str()); assert_eq!(request_thread_id, thread_id_string.as_str()); assert_eq!(request_originator, originator().value); - assert_eq!(request_authorization, "Bearer Test API Key"); + assert_eq!(request_authorization, format!("Bearer {api_key}")); + assert_eq!( + request.header("x-openai-fedramp").as_deref(), + expected_fedramp_header + ); assert_eq!( request_body["prompt_cache_key"].as_str(), Some(session_id_string.as_str()) diff --git a/codex-rs/ext/history-notes/src/backend.rs b/codex-rs/ext/history-notes/src/backend.rs index c3ef0245933f..fa9db40af6b0 100644 --- a/codex-rs/ext/history-notes/src/backend.rs +++ b/codex-rs/ext/history-notes/src/backend.rs @@ -53,11 +53,8 @@ impl HistoryNotesBackend { }), ); - let provider = self.provider.api_provider().await.map_err(|_| { - format!("{OPERATION_ERROR_PREFIX} Could not resolve the backend provider.") - })?; - let auth = self.provider.api_auth().await.map_err(|_| { - format!("{OPERATION_ERROR_PREFIX} Could not resolve backend authentication.") + let (provider, auth) = self.provider.api_provider_and_auth().await.map_err(|_| { + format!("{OPERATION_ERROR_PREFIX} Could not prepare the backend request.") })?; let mut request = provider.build_request(Method::POST, path); diff --git a/codex-rs/ext/image-generation/src/backend.rs b/codex-rs/ext/image-generation/src/backend.rs index cc18531775c3..694e7b08e3db 100644 --- a/codex-rs/ext/image-generation/src/backend.rs +++ b/codex-rs/ext/image-generation/src/backend.rs @@ -78,14 +78,9 @@ impl CodexImagesBackend { /// Resolves the provider and auth required for the current image API request. async fn client(&self) -> Result, ImageBackendError> { - let provider = self + let (provider, auth) = self .provider - .api_provider() - .await - .map_err(|err| ImageBackendError::from_message(err.to_string()))?; - let auth = self - .provider - .api_auth() + .api_provider_and_auth() .await .map_err(|err| ImageBackendError::from_message(err.to_string()))?; let transport = create_transport_for_routes_async( diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 411b405bd8c5..562ce4ce7894 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -100,14 +100,9 @@ impl WebSearchTool { ) -> Result, FunctionCallError> { let commands = parse_commands(&call)?; let command_action = command_action(&commands); - let provider = self + let (provider, auth) = self .provider - .api_provider() - .await - .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; - let auth = self - .provider - .api_auth() + .api_provider_and_auth() .await .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; let transport = create_transport_for_routes_async( diff --git a/codex-rs/login/src/auth/api_key.rs b/codex-rs/login/src/auth/api_key.rs new file mode 100644 index 000000000000..f5cafd2b4fed --- /dev/null +++ b/codex-rs/login/src/auth/api_key.rs @@ -0,0 +1,9 @@ +//! API-key routing hints. Prefixes do not validate credentials or grant access. + +/// Whether the key carries a FedRAMP issuance marker. +/// +/// Unmarked keys may predate the marker, so `false` does not establish that a key +/// is commercial. Authentication must still use the complete, unchanged key. +pub fn is_fedramp_api_key(api_key: &str) -> bool { + api_key.starts_with("sk-proj-fedramp-") || api_key.starts_with("sk-svcacct-fedramp-") +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index f31156205c49..c52d4f62fb74 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -147,26 +147,32 @@ fn login_with_api_key_overwrites_existing_auth_json() { "account_id": "stale-acc" } }); - std::fs::write( - &auth_path, - serde_json::to_string_pretty(&stale_auth).unwrap(), - ) - .unwrap(); - - super::login_with_api_key( - dir.path(), + for api_key in [ "sk-new", - AuthCredentialsStoreMode::File, - AuthKeyringBackendKind::default(), - ) - .expect("login_with_api_key should succeed"); + "sk-proj-fedramp-synthetic-test-key", + "sk-svcacct-fedramp-synthetic-test-key", + ] { + std::fs::write( + &auth_path, + serde_json::to_string_pretty(&stale_auth).unwrap(), + ) + .unwrap(); - let storage = FileAuthStorage::new(dir.path().to_path_buf()); - let auth = storage - .try_read_auth_json(&auth_path) - .expect("auth.json should parse"); - assert_eq!(auth.openai_api_key.as_deref(), Some("sk-new")); - assert!(auth.tokens.is_none(), "tokens should be cleared"); + super::login_with_api_key( + dir.path(), + api_key, + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + ) + .expect("login_with_api_key should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!(auth.openai_api_key.as_deref(), Some(api_key)); + assert!(auth.tokens.is_none(), "tokens should be cleared"); + } } #[tokio::test] @@ -1138,30 +1144,47 @@ async fn loads_api_key_from_auth_json() { let dir = tempdir().unwrap(); let _access_token_guard = remove_access_token_env_var(); let auth_file = dir.path().join("auth.json"); - std::fs::write( - auth_file, - r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#, - ) - .unwrap(); + for (api_key, expected_fedramp) in [ + ("sk-test-key", false), + ("sk-proj-fedramp-synthetic-test-key", true), + ("sk-svcacct-fedramp-synthetic-test-key", true), + ("sk-proj-synthetic-test-key", false), + ("sk-svcacct-synthetic-test-key", false), + ("sk-proj-fedramp", false), + ("sk-svcacct-fedrampish-test-key", false), + ("sk-proj-FEDRAMP-test-key", false), + ("sk-admin-fedramp-test-key", false), + ("other-sk-proj-fedramp-test-key", false), + ] { + std::fs::write( + &auth_file, + json!({"OPENAI_API_KEY": api_key, "tokens": null, "last_refresh": null}).to_string(), + ) + .unwrap(); - let auth = super::load_auth( - dir.path(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*allowed_login_methods*/ None, - /*forced_chatgpt_workspace_id*/ None, - /*chatgpt_base_url*/ None, - AuthKeyringBackendKind::Direct, - /*agent_identity_authapi_base_url*/ None, - &crate::test_support::transport_default_auth_route_config(), - ) - .await - .unwrap() - .unwrap(); - assert_eq!(auth.auth_mode(), AuthMode::ApiKey); - assert_eq!(auth.api_key(), Some("sk-test-key")); + let auth = super::load_auth( + dir.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*allowed_login_methods*/ None, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::Direct, + /*agent_identity_authapi_base_url*/ None, + &crate::test_support::transport_default_auth_route_config(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(auth.auth_mode(), AuthMode::ApiKey); + assert_eq!(auth.api_key(), Some(api_key)); + assert_eq!( + (auth.get_token().unwrap(), auth.is_fedramp_account()), + (api_key.to_string(), expected_fedramp) + ); - assert!(auth.get_token_data().is_err()); + assert!(auth.get_token_data().is_err()); + } } #[test] diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index b7778b0df751..ec292e7fcc71 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -47,6 +47,7 @@ use super::agent_identity::record_needs_task_registration; use super::agent_identity::register_managed_chatgpt_agent_identity; use super::agent_identity::require_agent_identity_authapi_base_url; use super::agent_identity::verified_record_from_jwt; +use super::api_key::is_fedramp_api_key; use super::change_state::AuthChangeState; use super::change_state::same_login; use super::change_state::same_owner; @@ -642,9 +643,10 @@ impl CodexAuth { } } - /// Returns false if Codex backend auth omits the FedRAMP claim. + /// Returns whether auth carries a FedRAMP routing hint from a claim or API-key prefix. pub fn is_fedramp_account(&self) -> bool { match self { + Self::ApiKey(auth) => is_fedramp_api_key(&auth.api_key), Self::Headers(_) => false, Self::AgentIdentity(auth) => auth.is_fedramp_account(), Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 68184e17ab5a..af0afd0e1da6 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,5 +1,6 @@ mod access_token; mod agent_identity; +mod api_key; mod auth_headers; mod bedrock_access_keys; mod bedrock_api_key; @@ -16,6 +17,7 @@ mod external_bearer; mod manager; mod revoke; +pub use api_key::is_fedramp_api_key; pub use auth_headers::AuthHeaders; pub use bedrock_access_keys::BedrockAccessKeysAuth; pub use bedrock_access_keys::login_with_bedrock_access_keys; diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 4140bb097026..2cafc0214ee0 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -353,6 +353,23 @@ fn bearer_auth_for_provider( Ok(None) } +/// Whether the selected API key enables OpenAI's default FedRAMP endpoints. +/// Command-backed providers retain their configured routing across token refreshes. +pub(crate) fn uses_fedramp_api_key( + auth: Option<&CodexAuth>, + provider: &ModelProviderInfo, +) -> codex_protocol::error::Result { + if !provider.is_openai() || provider.has_command_auth() { + return Ok(false); + } + if let Some(auth) = bearer_auth_for_provider(provider)? { + return Ok(auth.is_fedramp_account); + } + Ok(provider.requires_openai_auth + && matches!(auth, Some(CodexAuth::ApiKey(_))) + && auth.is_some_and(CodexAuth::is_fedramp_account)) +} + /// Builds request-header auth for a first-party Codex auth snapshot. pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { match auth { @@ -544,12 +561,14 @@ mod tests { "ChatGPT-Account-ID", HeaderValue::from_static("account-123"), ); - let ambient_auth = CodexAuth::Headers(AuthHeaders::new(ambient_headers)); - - let auth = - resolve_provider_auth(Some(&ambient_auth), &provider).expect("auth should resolve"); - - assert!(auth.to_auth_headers().unwrap().is_empty()); + for ambient_auth in [ + CodexAuth::Headers(AuthHeaders::new(ambient_headers)), + CodexAuth::from_api_key("sk-proj-fedramp-synthetic-test-key"), + ] { + let auth = + resolve_provider_auth(Some(&ambient_auth), &provider).expect("auth should resolve"); + assert!(auth.to_auth_headers().unwrap().is_empty()); + } } #[test] @@ -589,6 +608,41 @@ mod tests { assert_eq!(headers.len(), 1); } + #[test] + fn api_key_fedramp_routing_follows_the_selected_provider_credential() { + let project_key = "sk-proj-fedramp-synthetic-test-key"; + let service_account_key = "sk-svcacct-fedramp-synthetic-test-key"; + let legacy_key = "sk-proj-synthetic-test-key"; + + for (ambient_key, provider_key, expected_fedramp) in [ + (project_key, None, true), + (service_account_key, None, true), + (legacy_key, None, false), + (project_key, Some(legacy_key), false), + (legacy_key, Some(project_key), true), + (legacy_key, Some(service_account_key), true), + ] { + let mut provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None); + provider.experimental_bearer_token = provider_key.map(Into::into); + let ambient_auth = CodexAuth::from_api_key(ambient_key); + + let headers = resolve_provider_auth(Some(&ambient_auth), &provider) + .expect("auth should resolve") + .to_auth_headers() + .unwrap(); + + let key = provider_key.unwrap_or(ambient_key); + let mut expected_headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {key}")).unwrap(), + )]); + if expected_fedramp { + expected_headers.insert("X-OpenAI-Fedramp", HeaderValue::from_static("true")); + } + assert_eq!(headers, expected_headers); + } + } + #[test] fn custom_provider_uses_command_resolved_auth() { let mut provider = diff --git a/codex-rs/model-provider/src/bearer_auth_provider.rs b/codex-rs/model-provider/src/bearer_auth_provider.rs index 87ce528dc29b..3c6714190811 100644 --- a/codex-rs/model-provider/src/bearer_auth_provider.rs +++ b/codex-rs/model-provider/src/bearer_auth_provider.rs @@ -1,5 +1,6 @@ use codex_api::AuthProvider; use codex_http_client::CHATGPT_ACCOUNT_ID_HEADER; +use codex_login::auth::is_fedramp_api_key; use http::HeaderMap; use http::HeaderValue; @@ -13,10 +14,11 @@ pub struct BearerAuthProvider { impl BearerAuthProvider { pub fn new(token: String) -> Self { + let is_fedramp_account = is_fedramp_api_key(&token); Self { token: Some(token), account_id: None, - is_fedramp_account: false, + is_fedramp_account, } } diff --git a/codex-rs/model-provider/src/chatgpt_base_url.rs b/codex-rs/model-provider/src/chatgpt_base_url.rs new file mode 100644 index 000000000000..01b45c04211a --- /dev/null +++ b/codex-rs/model-provider/src/chatgpt_base_url.rs @@ -0,0 +1,40 @@ +//! Resolves ChatGPT backend defaults from the credentials used for a request. +//! Explicit endpoints remain authoritative across credential changes. + +use codex_login::CodexAuth; + +pub const DEFAULT_CHATGPT_BASE_URL: &str = "https://chatgpt.com/backend-api/"; +const FEDRAMP_CHATGPT_BASE_URL: &str = "https://gov.chatgpt.com/backend-api/"; + +/// A backend endpoint that retains whether configuration supplied an override. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum ChatgptBaseUrl { + #[default] + Default, + Configured(String), +} + +impl ChatgptBaseUrl { + /// Resolve with the same captured credentials that will authenticate the request. + pub fn resolve(&self, auth: &CodexAuth) -> &str { + match self { + Self::Configured(url) => url, + Self::Default if auth.is_api_key_auth() && auth.is_fedramp_account() => { + FEDRAMP_CHATGPT_BASE_URL + } + Self::Default => DEFAULT_CHATGPT_BASE_URL, + } + } +} + +impl From for ChatgptBaseUrl { + fn from(url: String) -> Self { + Self::Configured(url) + } +} + +impl From<&str> for ChatgptBaseUrl { + fn from(url: &str) -> Self { + Self::Configured(url.to_owned()) + } +} diff --git a/codex-rs/model-provider/src/lib.rs b/codex-rs/model-provider/src/lib.rs index 7aa8a0a4540b..4b13035963c2 100644 --- a/codex-rs/model-provider/src/lib.rs +++ b/codex-rs/model-provider/src/lib.rs @@ -2,6 +2,7 @@ mod amazon_bedrock; mod auth; mod bearer_auth_provider; mod capabilities; +mod chatgpt_base_url; mod combined_auth; mod gcp; mod models_endpoint; @@ -27,6 +28,8 @@ pub use auth::unauthenticated_auth_provider; pub use bearer_auth_provider::BearerAuthProvider; pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider; pub use capabilities::ProviderCapabilities; +pub use chatgpt_base_url::ChatgptBaseUrl; +pub use chatgpt_base_url::DEFAULT_CHATGPT_BASE_URL; pub use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; pub use codex_model_provider_info::AMAZON_BEDROCK_RUNTIME_PROVIDER_ID; pub use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; @@ -43,6 +46,7 @@ pub use provider::ProviderUnauthorizedRecovery; pub use provider::SharedModelProvider; pub use provider::create_model_provider; pub use provider::provider_uses_first_party_auth_path; +pub use provider::resolve_api_provider; #[cfg(test)] #[path = "workspace_routing_tests.rs"] diff --git a/codex-rs/model-provider/src/models_endpoint.rs b/codex-rs/model-provider/src/models_endpoint.rs index ba4cc4bef714..31bb98636e97 100644 --- a/codex-rs/model-provider/src/models_endpoint.rs +++ b/codex-rs/model-provider/src/models_endpoint.rs @@ -40,10 +40,13 @@ use tokio::time::timeout; use crate::auth::ResolvedProviderAuth; use crate::auth::agent_identity_telemetry; use crate::auth::resolve_provider_auth; +use crate::auth::uses_fedramp_api_key; use crate::combined_auth::compose_auth; +use crate::provider::resolve_api_provider; const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); const MODELS_ENDPOINT: &str = "/models"; +const FEDRAMP_CODEX_BASE_URL: &str = "https://gov.chatgpt.com/backend-api/codex"; /// Provider-owned OpenAI-compatible `/models` endpoint. #[derive(Debug)] @@ -108,14 +111,19 @@ impl OpenAiModelsEndpoint { ); let identity = crate::models_identity::identity(&self.provider_info, auth.as_ref())?; let auth_mode = auth.as_ref().map(CodexAuth::auth_mode); - let mut api_provider = self.provider_info.to_api_provider(auth_mode)?; + let mut api_provider = resolve_api_provider(&self.provider_info, auth.as_ref())?; if (auth.as_ref().is_some_and(CodexAuth::is_api_key_auth) || self.has_provider_api_key()) && self.supports_api_key_models() && self.provider_info.base_url.is_none() && self.provider_info.model_catalog_url.is_none() { // Codex metadata is served by the Codex backend, not the public /v1/models API. - api_provider.base_url = CHATGPT_CODEX_BASE_URL.to_string(); + api_provider.base_url = if uses_fedramp_api_key(auth.as_ref(), &self.provider_info)? { + FEDRAMP_CODEX_BASE_URL + } else { + CHATGPT_CODEX_BASE_URL + } + .to_string(); } let resolved = compose_auth( &self.provider_info, @@ -432,7 +440,6 @@ mod tests { use codex_models_manager::manager::OpenAiModelsManager; use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::model_info::model_info_from_slug; - use codex_protocol::auth::AuthMode; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; @@ -484,28 +491,59 @@ mod tests { #[tokio::test] async fn api_key_discovery_respects_provider_routing() { let client_version = codex_models_manager::client_version_to_whole(); - for (name, base_url, models_url, inference_url) in [ + for (api_key, name, base_url, models_url, inference_url) in [ ( + "test-api-key", "OpenAI", None, Some("https://chatgpt.com/backend-api/codex/models"), "https://api.openai.com/v1", ), ( + "test-api-key", "OpenAI", Some("https://example.com/codex"), None, "https://example.com/codex", ), ( + "test-api-key", "Azure", Some("https://example.openai.azure.com/openai/v1"), None, "https://example.openai.azure.com/openai/v1", ), + ( + "sk-proj-fedramp-synthetic-test-key", + "OpenAI", + None, + Some("https://gov.chatgpt.com/backend-api/codex/models"), + "https://gov.api.openai.com/v1", + ), + ( + "sk-svcacct-fedramp-synthetic-test-key", + "OpenAI", + None, + Some("https://gov.chatgpt.com/backend-api/codex/models"), + "https://gov.api.openai.com/v1", + ), + ( + "sk-proj-fedramp-synthetic-test-key", + "OpenAI", + Some("https://example.com/codex"), + None, + "https://example.com/codex", + ), + ( + "sk-svcacct-fedramp-synthetic-test-key", + "Azure", + None, + None, + "https://api.openai.com/v1", + ), ] { let capture = Arc::new(CaptureModelsUrl(Mutex::new(/*t*/ None))); - let auth = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test-api-key")); + let auth = AuthManager::from_auth_for_testing(CodexAuth::from_api_key(api_key)); let endpoint = Arc::new(OpenAiModelsEndpoint { provider_info: ModelProviderInfo { name: name.to_string(), @@ -515,7 +553,11 @@ mod tests { gateway_auth_manager: None, transport_builder: capture.clone(), }); - let manager = OpenAiModelsManager::new_without_cache(endpoint.clone(), Some(auth)); + let provider = crate::provider::create_model_provider( + endpoint.provider_info.clone(), + Some(auth.clone()), + ); + let manager = OpenAiModelsManager::new_without_cache(endpoint, Some(auth)); manager.set_api_key_model_discovery_enabled(/*enabled*/ true); manager .raw_model_catalog( @@ -528,11 +570,7 @@ mod tests { models_url.map(|url| format!("{url}?client_version={client_version}")) ); assert_eq!( - endpoint - .provider_info - .to_api_provider(Some(AuthMode::ApiKey)) - .unwrap() - .base_url, + provider.api_provider().await.unwrap().base_url, inference_url ); } @@ -540,21 +578,59 @@ mod tests { #[tokio::test] async fn provider_api_key_without_endpoint_overrides_uses_codex_backend() { - for auth in [ - None, - Some(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + for (api_key, auth, models_base_url, inference_url) in [ + ( + "provider-key", + None, + CHATGPT_CODEX_BASE_URL, + "https://api.openai.com/v1", + ), + ( + "provider-key", + Some(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + CHATGPT_CODEX_BASE_URL, + CHATGPT_CODEX_BASE_URL, + ), + ( + "provider-key", + Some(CodexAuth::from_api_key("sk-proj-fedramp-ambient-key")), + CHATGPT_CODEX_BASE_URL, + "https://api.openai.com/v1", + ), + ( + "sk-proj-fedramp-provider-key", + None, + "https://gov.chatgpt.com/backend-api/codex", + "https://gov.api.openai.com/v1", + ), + ( + "sk-svcacct-fedramp-provider-key", + Some(CodexAuth::from_api_key("legacy-ambient-key")), + "https://gov.chatgpt.com/backend-api/codex", + "https://gov.api.openai.com/v1", + ), + ( + "sk-proj-fedramp-provider-key", + Some(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + "https://gov.chatgpt.com/backend-api/codex", + "https://gov.api.openai.com/v1", + ), ] { let capture = Arc::new(CaptureModelsUrl(Mutex::new(/*t*/ None))); let auth_manager = auth.map(AuthManager::from_auth_for_testing); let endpoint = Arc::new(OpenAiModelsEndpoint { provider_info: ModelProviderInfo { - experimental_bearer_token: Some("provider-key".into()), + experimental_bearer_token: Some(api_key.into()), ..ModelProviderInfo::create_openai_provider(/*base_url*/ None) }, auth_manager: auth_manager.clone(), gateway_auth_manager: None, transport_builder: capture.clone(), }); + let provider = crate::provider::create_model_provider( + endpoint.provider_info.clone(), + auth_manager.clone(), + ); let manager = OpenAiModelsManager::new_without_cache(endpoint, auth_manager); manager.set_api_key_model_discovery_enabled(/*enabled*/ true); manager @@ -566,10 +642,14 @@ mod tests { assert_eq!( *capture.0.lock().unwrap(), Some(format!( - "{CHATGPT_CODEX_BASE_URL}/models?client_version={}", + "{models_base_url}/models?client_version={}", codex_models_manager::client_version_to_whole(), )) ); + assert_eq!( + provider.api_provider().await.unwrap().base_url, + inference_url + ); } } @@ -599,6 +679,13 @@ mod tests { ); assert!(endpoint.has_command_auth()); + let auth = CodexAuth::from_api_key("sk-proj-fedramp-command-key"); + assert_eq!( + resolve_api_provider(&endpoint.provider_info, Some(&auth)) + .unwrap() + .base_url, + "https://api.openai.com/v1" + ); } #[test] @@ -822,6 +909,8 @@ mod tests { for auth in [ CodexAuth::create_dummy_chatgpt_auth_for_testing(), CodexAuth::from_api_key("test-key"), + CodexAuth::from_api_key("sk-proj-fedramp-synthetic-test-key"), + CodexAuth::from_api_key("sk-svcacct-fedramp-synthetic-test-key"), ] { let server = MockServer::start().await; let mut model = model_info_from_slug("provider-model"); diff --git a/codex-rs/model-provider/src/models_identity.rs b/codex-rs/model-provider/src/models_identity.rs index 713064a24cf6..356edf9c8f19 100644 --- a/codex-rs/model-provider/src/models_identity.rs +++ b/codex-rs/model-provider/src/models_identity.rs @@ -10,12 +10,13 @@ use sha2::Digest; use sha2::Sha256; use crate::auth::resolve_provider_auth; +use crate::provider::resolve_api_provider; pub(crate) fn identity( provider_info: &ModelProviderInfo, auth: Option<&CodexAuth>, ) -> CoreResult { - let mut api_provider = provider_info.to_api_provider(auth.map(CodexAuth::auth_mode))?; + let mut api_provider = resolve_api_provider(provider_info, auth)?; let mut digest = Sha256::new(); // Length-prefix every field to avoid ambiguity between adjacent values. let mut field = |value: &[u8]| { diff --git a/codex-rs/model-provider/src/models_identity_tests.rs b/codex-rs/model-provider/src/models_identity_tests.rs index 16f88b41ae95..0dc9e9f19078 100644 --- a/codex-rs/model-provider/src/models_identity_tests.rs +++ b/codex-rs/model-provider/src/models_identity_tests.rs @@ -45,10 +45,10 @@ fn cache_identity_tracks_account_email_user_plan_and_auth_mode_but_not_token_ref #[test] fn cache_identity_tracks_provider_routing_and_effective_api_credentials() { - let auth = CodexAuth::from_api_key("first-key"); + let auth = CodexAuth::from_api_key("sk-proj-fedramp-synthetic-test-key"); let provider = ModelProviderInfo::create_openai_provider(Some("https://one.example/v1".into())); let key = identity(&provider, Some(&auth)).unwrap(); - let other_key = CodexAuth::from_api_key("second-key"); + let other_key = CodexAuth::from_api_key("sk-proj-fedramp-rotated-test-key"); assert_ne!(identity(&provider, Some(&other_key)).unwrap(), key); let mut other_provider = provider.clone(); other_provider.base_url = Some("https://two.example/v1".into()); @@ -62,6 +62,18 @@ fn cache_identity_tracks_provider_routing_and_effective_api_credentials() { "another-project".into(), )])); assert_ne!(identity(&other_provider, Some(&auth)).unwrap(), key); + + let mut provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None); + // Keep identities stable while the endpoint residency test runs in parallel. + provider.http_headers = Some(std::collections::HashMap::from([( + codex_login::default_client::RESIDENCY_HEADER_NAME.to_string(), + "us".into(), + )])); + let key = identity(&provider, Some(&auth)).unwrap(); + provider.base_url = Some("https://gov.api.openai.com/v1".into()); + assert_eq!(identity(&provider, Some(&auth)).unwrap(), key); + provider.base_url = Some("https://api.openai.com/v1".into()); + assert_ne!(identity(&provider, Some(&auth)).unwrap(), key); } #[test] diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index c3c18c4ab5ea..d5cab7cc2059 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -32,6 +32,7 @@ use crate::auth::ResolvedProviderAuth; use crate::auth::auth_manager_for_provider; use crate::auth::resolve_provider_auth; use crate::auth::resolve_provider_auth_for_scope; +use crate::auth::uses_fedramp_api_key; use crate::combined_auth::compose_auth; use crate::gcp::GcpModelProvider; use crate::models_endpoint::OpenAiModelsEndpoint; @@ -93,6 +94,8 @@ pub const DEFAULT_APPROVAL_REVIEW_PREFERRED_MODEL: &str = "codex-auto-review"; const API_KEY_APPROVAL_REVIEW_PREFERRED_MODEL: &str = "gpt-5.6-luna"; +const FEDRAMP_API_BASE_URL: &str = "https://gov.api.openai.com/v1"; + /// Default model used for memory extraction when a provider does not require a /// backend-specific model ID. pub const DEFAULT_MEMORY_EXTRACTION_PREFERRED_MODEL: &str = "gpt-5.6-luna"; @@ -216,6 +219,15 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { }) } + /// Returns the endpoint and authentication for one API client. + /// Providers with credential-dependent routing should resolve both from the same auth read. + fn api_provider_and_auth( + &self, + ) -> ModelProviderFuture<'_, codex_protocol::error::Result<(Provider, SharedAuthProvider)>> + { + Box::pin(async move { Ok((self.api_provider().await?, self.api_auth().await?)) }) + } + /// Resolves routing for Responses HTTP, compaction, and WebSocket handshakes. fn responses_api_provider<'a>( &'a self, @@ -277,7 +289,7 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { &self, auth: Option<&CodexAuth>, ) -> codex_protocol::error::Result { - self.info().to_api_provider(auth.map(CodexAuth::auth_mode)) + resolve_api_provider(self.info(), auth) } /// Returns the provider base URL that will be used at request time. @@ -362,6 +374,18 @@ pub type ModelProviderFuture<'a, T> = Pin + Send + 'a /// Shared runtime model provider handle. pub type SharedModelProvider = Arc; +/// Resolve credential-dependent defaults without changing configured endpoints. +pub fn resolve_api_provider( + info: &ModelProviderInfo, + auth: Option<&CodexAuth>, +) -> codex_protocol::error::Result { + let mut provider = info.to_api_provider(auth.map(CodexAuth::auth_mode))?; + if info.base_url.is_none() && uses_fedramp_api_key(auth, info)? { + provider.base_url = FEDRAMP_API_BASE_URL.to_string(); + } + Ok(provider) +} + pub fn provider_uses_first_party_auth_path(provider: &ModelProviderInfo) -> bool { provider.requires_openai_auth && provider.env_key.is_none() @@ -523,6 +547,22 @@ impl ModelProvider for ConfiguredModelProvider { }) } + fn api_provider_and_auth( + &self, + ) -> ModelProviderFuture<'_, codex_protocol::error::Result<(Provider, SharedAuthProvider)>> + { + Box::pin(async move { + let auth = self.auth().await; + let provider = self.api_provider_for_auth(auth.as_ref())?; + let primary = resolve_provider_auth(auth.as_ref(), &self.info)?; + let auth = self + .compose_auth(ResolvedProviderAuth::new(primary)) + .await? + .auth; + Ok((provider, auth)) + }) + } + fn compose_auth( &self, resolved: ResolvedProviderAuth, @@ -627,6 +667,8 @@ impl ModelProvider for ConfiguredModelProvider { mod tests { use std::future::Future; use std::num::NonZeroU64; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; use std::task::Context; use std::task::Waker; @@ -851,8 +893,30 @@ mod tests { ); } + struct RotatingAuth(AtomicUsize); + + impl codex_login::ExternalAuth for RotatingAuth { + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async move { + let key = if self.0.fetch_add(1, Ordering::SeqCst).is_multiple_of(2) { + "sk-proj-fedramp-synthetic-test-key" + } else { + "commercial-key" + }; + Ok(CodexAuth::from_api_key(key)) + }) + } + + fn refresh( + &self, + _context: codex_login::ExternalAuthRefreshContext, + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + self.resolve() + } + } + #[tokio::test] - async fn configured_provider_runtime_base_url_uses_configured_base_url() { + async fn configured_provider_resolves_default_and_explicit_endpoints() { let provider = create_model_provider( provider_for("https://example.test/v1".to_string()), /*auth_manager*/ None, @@ -865,6 +929,79 @@ mod tests { .expect("runtime base URL should resolve"), Some("https://example.test/v1".to_string()) ); + assert_eq!( + provider.api_provider_and_auth().await.unwrap().0.base_url, + "https://example.test/v1" + ); + + let home = tempfile::tempdir().unwrap(); + let auth = AuthManager::from_auth_for_testing_with_home( + CodexAuth::from_api_key("initial-key"), + home.path().to_path_buf(), + ); + let provider = create_model_provider( + ModelProviderInfo::create_openai_provider(/*base_url*/ None), + Some(auth.clone()), + ); + for (api_key, expected_base_url) in [ + ("legacy-key", "https://api.openai.com/v1"), + ( + "sk-proj-fedramp-synthetic-test-key", + "https://gov.api.openai.com/v1", + ), + ("commercial-key", "https://api.openai.com/v1"), + ] { + codex_login::login_with_api_key( + home.path(), + api_key, + codex_login::AuthCredentialsStoreMode::File, + codex_login::AuthKeyringBackendKind::default(), + ) + .unwrap(); + auth.reload().await; + let (api_provider, api_auth) = provider.api_provider_and_auth().await.unwrap(); + assert_eq!(api_provider.base_url, expected_base_url); + let headers = api_auth.to_auth_headers().unwrap(); + assert_eq!( + headers[http::header::AUTHORIZATION].to_str().unwrap(), + format!("Bearer {api_key}") + ); + } + assert_eq!(provider.info().base_url, None); + + let rotating_auth = Arc::new(RotatingAuth(AtomicUsize::new(0))); + auth.set_external_auth(rotating_auth.clone()).await.unwrap(); + for (api_key, expected_base_url, expected_fedramp_header) in [ + ("commercial-key", "https://api.openai.com/v1", None), + ( + "sk-proj-fedramp-synthetic-test-key", + "https://gov.api.openai.com/v1", + Some("true"), + ), + ("commercial-key", "https://api.openai.com/v1", None), + ] { + let reads_before = rotating_auth.0.load(Ordering::SeqCst); + let (api_provider, api_auth) = provider.api_provider_and_auth().await.unwrap(); + assert_eq!(rotating_auth.0.load(Ordering::SeqCst), reads_before + 1); + let headers = api_auth.to_auth_headers().unwrap(); + let expected_authorization = format!("Bearer {api_key}"); + assert_eq!( + ( + api_provider.base_url.as_str(), + headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + headers + .get("X-OpenAI-Fedramp") + .and_then(|value| value.to_str().ok()), + ), + ( + expected_base_url, + Some(expected_authorization.as_str()), + expected_fedramp_header, + ) + ); + } } #[test] diff --git a/codex-rs/model-provider/src/shared_state_tests.rs b/codex-rs/model-provider/src/shared_state_tests.rs index 863d4efd96cb..6039fbb77d66 100644 --- a/codex-rs/model-provider/src/shared_state_tests.rs +++ b/codex-rs/model-provider/src/shared_state_tests.rs @@ -105,7 +105,7 @@ async fn gateway_credentials_accompany_primary_auth_in_models_and_responses() { models .raw_model_catalog(RefreshStrategy::Online, factory.clone()) .await; - let auth = provider.api_auth().await.unwrap(); + let (api, auth) = provider.api_provider_and_auth().await.unwrap(); let scoped = provider .api_auth_for_scope(ProviderAuthScope { agent_identity_policy: AgentIdentityAuthPolicy::JwtOnly, @@ -137,7 +137,6 @@ async fn gateway_credentials_accompany_primary_auth_in_models_and_responses() { assert_eq!(auth.resolve_auth_headers().await.unwrap(), expected); assert_eq!(scoped.auth.to_auth_headers().unwrap(), expected); assert!(auth.to_auth_headers().unwrap()[name].is_sensitive()); - let api = info.to_api_provider(/*auth_mode*/ None).unwrap(); let transport = ReqwestTransport::from_http_client( create_client_for_route( &factory, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index eff25f59261d..afc615597429 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -358,6 +358,7 @@ async fn new_config( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()), + chatgpt_base_url_is_explicit: auth_config.chatgpt_base_url.is_some(), respect_system_proxy, apps_mcp_product_sku: None, responses_api_metadata: BTreeMap::new(), From b8f6d25ce7d19d579811cbb47873f19071780ea9 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Fri, 23 Oct 2026 20:24:50 +0000 Subject: [PATCH 014/287] Consolidate async header authentication in `apply_auth_headers` (#57179) ## What changed Remove `AuthProvider::resolve_auth_headers` and use `apply_auth_headers` as the async interface for header-only authentication. Migrate providers, callers, and existing tests to the shared interface, passing an empty `HeaderMap` when only authentication headers are needed. Document that providers may refresh credentials asynchronously and that callers must use the returned header map so providers can remove superseded credentials. GitOrigin-RevId: 42449de6fddd2be2c50c475c8e92628493197007 --- codex-rs/cli/src/exec_server_credential.rs | 8 +++--- .../cli/src/exec_server_credential_tests.rs | 27 +++++++++++++------ codex-rs/codex-api/src/auth.rs | 18 +++++-------- codex-rs/codex-api/tests/clients.rs | 9 ++++--- codex-rs/exec-server/src/remote.rs | 7 +++-- codex-rs/exec-server/src/remote/auth_tests.rs | 7 ++--- .../src/remote/registration_retry_tests.rs | 4 +-- codex-rs/model-provider/src/auth.rs | 13 ++++----- .../model-provider/src/auth_refresh_tests.rs | 8 +++--- codex-rs/model-provider/src/combined_auth.rs | 13 --------- .../model-provider/src/shared_state_tests.rs | 5 +++- codex-rs/runtime-agent-instance/src/auth.rs | 2 +- .../src/manager_tests.rs | 12 ++++----- 13 files changed, 66 insertions(+), 67 deletions(-) diff --git a/codex-rs/cli/src/exec_server_credential.rs b/codex-rs/cli/src/exec_server_credential.rs index 86a33fdf96be..bd0719d7755f 100644 --- a/codex-rs/cli/src/exec_server_credential.rs +++ b/codex-rs/cli/src/exec_server_credential.rs @@ -128,9 +128,11 @@ impl AuthProvider for ExecutorCredentialProvider { ) -> codex_api::AuthRefreshFuture<'_> { Box::pin(async move { // The parent owns refresh; only retry if it already supplied a valid replacement. - self.resolve_auth_headers().await.is_ok_and(|headers| { - headers.get(AUTHORIZATION) != rejected_headers.get(AUTHORIZATION) - }) + self.apply_auth_headers(HeaderMap::new()) + .await + .is_ok_and(|headers| { + headers.get(AUTHORIZATION) != rejected_headers.get(AUTHORIZATION) + }) }) } } diff --git a/codex-rs/cli/src/exec_server_credential_tests.rs b/codex-rs/cli/src/exec_server_credential_tests.rs index 8599f61b1be4..5a59e8bf26c0 100644 --- a/codex-rs/cli/src/exec_server_credential_tests.rs +++ b/codex-rs/cli/src/exec_server_credential_tests.rs @@ -42,14 +42,17 @@ async fn waits_for_initial_rotation_replaces_token_and_eof_clears_auth() { .await, Err(CredentialError::InitialTimeout) ); - assert!(provider.resolve_auth_headers().await.is_err()); + assert!(provider.apply_auth_headers(HeaderMap::new()).await.is_err()); assert!(provider.to_auth_headers().is_err()); writer .write_all(&frame("env-a", "first-scoped-token", expires)) .expect("write"); provider.wait_for_initial().await.expect("initial token"); next_update(&mut updates).await; - let headers = provider.resolve_auth_headers().await.expect("headers"); + let headers = provider + .apply_auth_headers(HeaderMap::new()) + .await + .expect("headers"); assert_eq!(headers.len(), 1); assert_eq!(headers[AUTHORIZATION], "Bearer first-scoped-token"); assert!(headers[AUTHORIZATION].is_sensitive()); @@ -62,7 +65,10 @@ async fn waits_for_initial_rotation_replaces_token_and_eof_clears_auth() { next_update(&mut updates).await; // A request sent before the parent update can retry with the replacement. assert!(provider.refresh_after_unauthorized(headers).await); - let headers = provider.resolve_auth_headers().await.expect("headers"); + let headers = provider + .apply_auth_headers(HeaderMap::new()) + .await + .expect("headers"); assert_eq!(headers.len(), 1); assert_eq!(headers[AUTHORIZATION], "Bearer rotated-scoped-token"); assert!(headers[AUTHORIZATION].is_sensitive()); @@ -70,7 +76,7 @@ async fn waits_for_initial_rotation_replaces_token_and_eof_clears_auth() { drop(writer); pipe.parent_closed.await.expect("reader must monitor EOF"); - assert!(provider.resolve_auth_headers().await.is_err()); + assert!(provider.apply_auth_headers(HeaderMap::new()).await.is_err()); assert!(!provider.refresh_after_unauthorized(headers).await); assert_eq!(provider.check_pipe_failure(), Ok(())); } @@ -114,7 +120,7 @@ async fn malformed_rotations_latch_failure_and_clear_existing_credential() { .expect("bad frame terminates without EOF"); let auth_error = pipe .provider - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .expect_err("reject auth"); assert_eq!(pipe.provider.check_pipe_failure(), Err(error)); @@ -152,7 +158,12 @@ async fn eof_before_a_complete_frame_never_authenticates() { drop(writer); pipe.parent_closed.await.expect("reader monitors EOF"); assert_eq!(pipe.provider.wait_for_initial().await, Err(expected)); - assert!(pipe.provider.resolve_auth_headers().await.is_err()); + assert!( + pipe.provider + .apply_auth_headers(HeaderMap::new()) + .await + .is_err() + ); } } @@ -168,14 +179,14 @@ async fn expiry_is_checked_again_on_every_request() { provider.wait_for_initial().await, Err(CredentialError::Expired) ); - assert!(provider.resolve_auth_headers().await.is_err()); + assert!(provider.apply_auth_headers(HeaderMap::new()).await.is_err()); assert!(!provider.refresh_after_unauthorized(HeaderMap::new()).await); updates.send_replace(CredentialState::Active { token: HeaderValue::from_static("new-scoped-token"), expires_at: expired + 60, }); let headers = provider - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .expect("rotation restores auth"); assert_eq!(headers[AUTHORIZATION], "Bearer new-scoped-token"); diff --git a/codex-rs/codex-api/src/auth.rs b/codex-rs/codex-api/src/auth.rs index 6d1ab4f5131e..c72dc2d7e6a8 100644 --- a/codex-rs/codex-api/src/auth.rs +++ b/codex-rs/codex-api/src/auth.rs @@ -36,7 +36,7 @@ pub trait AuthProvider: Send + Sync { /// /// Implementations should be cheap and non-blocking. This method is also /// used by telemetry and non-HTTP request paths. Providers that require async - /// work must return an error here and override `resolve_auth_headers`. + /// work must return an error here and override `apply_auth_headers`. /// Callers must not send the request if this returns an error. fn add_auth_headers(&self, headers: &mut HeaderMap) -> Result<(), AuthError>; @@ -60,15 +60,6 @@ pub trait AuthProvider: Send + Sync { }) } - /// Resolves auth headers for an outbound request. - /// - /// Unlike [`Self::to_auth_headers`], implementations may perform asynchronous work to refresh - /// credentials before returning. Header-only providers with static credentials can rely on the - /// default implementation. - fn resolve_auth_headers(&self) -> AuthHeadersFuture<'_> { - Box::pin(async { self.to_auth_headers() }) - } - /// Refreshes credentials after an explicit HTTP 401. Returns whether the caller /// may retry with new credentials. Callers must bound retries and pass the headers /// of the rejected request so concurrent failures can reuse an earlier refresh. @@ -77,10 +68,13 @@ pub trait AuthProvider: Send + Sync { } /// Authenticates a header-only request, including removing credentials it supersedes. - /// WebSocket handshakes must use the returned map rather than overlaying resolved headers. + /// + /// Implementations may refresh credentials asynchronously. Providers with static credentials + /// can rely on the default implementation. + /// Callers must use the returned map rather than overlaying resolved headers. fn apply_auth_headers(&self, mut headers: HeaderMap) -> AuthHeadersFuture<'_> { Box::pin(async move { - headers.extend(self.resolve_auth_headers().await?); + headers.extend(self.to_auth_headers()?); Ok(headers) }) } diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 4410c15d7513..664697da323a 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -734,12 +734,13 @@ impl AuthProvider for AsyncHeaderAuth { )) } - fn resolve_auth_headers(&self) -> codex_api::AuthHeadersFuture<'_> { - Box::pin(async { - Ok(HeaderMap::from_iter([( + fn apply_auth_headers(&self, mut headers: HeaderMap) -> codex_api::AuthHeadersFuture<'_> { + Box::pin(async move { + headers.insert( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer async-auth"), - )])) + ); + Ok(headers) }) } } diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index a9a42cef45b6..a43f5ac5673c 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -305,7 +305,7 @@ impl EnvironmentRegistryClient { async fn resolve_auth(&self) -> Result<(HeaderMap, Option), ExecServerError> { let mut headers = self .auth_provider - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .map_err(|error| { ExecServerError::EnvironmentRegistryAuth(format!( @@ -1132,9 +1132,8 @@ mod tests { Ok(()) } - fn resolve_auth_headers(&self) -> codex_api::AuthHeadersFuture<'_> { - Box::pin(async { - let mut headers = HeaderMap::new(); + fn apply_auth_headers(&self, mut headers: HeaderMap) -> codex_api::AuthHeadersFuture<'_> { + Box::pin(async move { let _ = headers.insert( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer registry-token"), diff --git a/codex-rs/exec-server/src/remote/auth_tests.rs b/codex-rs/exec-server/src/remote/auth_tests.rs index 4c6117830332..2b11e20d0dbb 100644 --- a/codex-rs/exec-server/src/remote/auth_tests.rs +++ b/codex-rs/exec-server/src/remote/auth_tests.rs @@ -55,15 +55,16 @@ impl AuthProvider for RotatingAuth { Ok(()) } - fn resolve_auth_headers(&self) -> codex_api::AuthHeadersFuture<'_> { - Box::pin(async { + fn apply_auth_headers(&self, mut headers: HeaderMap) -> codex_api::AuthHeadersFuture<'_> { + Box::pin(async move { self.stall_at(if self.refreshes.load(Ordering::SeqCst) == 0 { AuthStage::Initial } else { AuthStage::Retry }) .await; - self.to_auth_headers() + self.add_auth_headers(&mut headers)?; + Ok(headers) }) } diff --git a/codex-rs/exec-server/src/remote/registration_retry_tests.rs b/codex-rs/exec-server/src/remote/registration_retry_tests.rs index bae77ef078b8..81f642f077f0 100644 --- a/codex-rs/exec-server/src/remote/registration_retry_tests.rs +++ b/codex-rs/exec-server/src/remote/registration_retry_tests.rs @@ -47,9 +47,9 @@ impl AuthProvider for RegistrationAuthProvider { Ok(()) } - fn resolve_auth_headers(&self) -> codex_api::AuthHeadersFuture<'_> { + fn apply_auth_headers(&self, headers: HeaderMap) -> codex_api::AuthHeadersFuture<'_> { self.calls.fetch_add(1, Ordering::Relaxed); - Box::pin(async { Ok(HeaderMap::new()) }) + Box::pin(async move { Ok(headers) }) } } diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 2cafc0214ee0..ba50f628bf92 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -156,7 +156,7 @@ impl AuthProvider for AuthManagerAuthProvider { auth_provider_from_auth(&auth).add_auth_headers(headers) } - fn resolve_auth_headers(&self) -> AuthHeadersFuture<'_> { + fn apply_auth_headers(&self, mut headers: HeaderMap) -> AuthHeadersFuture<'_> { Box::pin(async move { let auth = self .auth_manager @@ -166,7 +166,8 @@ impl AuthProvider for AuthManagerAuthProvider { .ok_or_else(|| { AuthError::Transient("managed authentication is unavailable".to_string()) })?; - auth_provider_from_auth(&auth).to_auth_headers() + headers.extend(auth_provider_from_auth(&auth).to_auth_headers()?); + Ok(headers) }) } @@ -751,7 +752,7 @@ mod tests { .expect("initial auth should be cached"); let provider = auth_provider_from_auth_manager(Arc::clone(&auth_manager), &expected_auth); let initial_headers = provider - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .expect("initial auth headers"); @@ -771,7 +772,7 @@ mod tests { // Ordinary requests retain the manager's cache until refresh or recovery runs. assert_eq!( provider - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .expect("cached auth headers"), initial_headers @@ -785,7 +786,7 @@ mod tests { ); let resolved_headers = provider - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .expect("managed auth headers should resolve"); assert_eq!( @@ -808,7 +809,7 @@ mod tests { Err(AuthError::Transient(_)) )); assert!(matches!( - provider.resolve_auth_headers().await, + provider.apply_auth_headers(HeaderMap::new()).await, Err(AuthError::Transient(_)) )); } diff --git a/codex-rs/model-provider/src/auth_refresh_tests.rs b/codex-rs/model-provider/src/auth_refresh_tests.rs index 2f5b3ad23a79..7489edbb6d4a 100644 --- a/codex-rs/model-provider/src/auth_refresh_tests.rs +++ b/codex-rs/model-provider/src/auth_refresh_tests.rs @@ -101,7 +101,7 @@ async fn concurrent_external_rejections_share_one_refresh() { assert!(provider.refresh_after_unauthorized(rejected).await); assert_eq!(external.refreshes.load(Ordering::SeqCst), 1); assert_eq!( - provider.resolve_auth_headers().await.unwrap()[http::header::AUTHORIZATION], + provider.apply_auth_headers(HeaderMap::new()).await.unwrap()[http::header::AUTHORIZATION], "Bearer replacement" ); } @@ -186,7 +186,7 @@ async fn check_concurrent_refresh(trigger: RefreshTrigger) { .await } RefreshTrigger::Proactive => { - let headers = provider.resolve_auth_headers().await.unwrap(); + let headers = provider.apply_auth_headers(HeaderMap::new()).await.unwrap(); assert_eq!( headers[http::header::AUTHORIZATION], "Bearer replacement-token" @@ -213,7 +213,7 @@ async fn check_concurrent_refresh(trigger: RefreshTrigger) { .refresh_after_unauthorized(rejected_headers.clone()) .await ); - let updated_headers = provider.resolve_auth_headers().await.unwrap(); + let updated_headers = provider.apply_auth_headers(HeaderMap::new()).await.unwrap(); assert_eq!( updated_headers[http::header::AUTHORIZATION], "Bearer replacement-token" @@ -234,7 +234,7 @@ async fn check_concurrent_refresh(trigger: RefreshTrigger) { .await; assert!(provider.refresh_after_unauthorized(updated_headers).await); assert_eq!( - provider.resolve_auth_headers().await.unwrap()[http::header::AUTHORIZATION], + provider.apply_auth_headers(HeaderMap::new()).await.unwrap()[http::header::AUTHORIZATION], "Bearer next-token" ); // A permanent failure must not prevent a later guarded reload of fresh credentials. diff --git a/codex-rs/model-provider/src/combined_auth.rs b/codex-rs/model-provider/src/combined_auth.rs index 586a8bd7e6f3..da79c4ebeaba 100644 --- a/codex-rs/model-provider/src/combined_auth.rs +++ b/codex-rs/model-provider/src/combined_auth.rs @@ -120,19 +120,6 @@ impl AuthProvider for CombinedAuth { Ok(()) } - fn resolve_auth_headers(&self) -> AuthHeadersFuture<'_> { - Box::pin(async move { - let mut headers = self.primary.resolve_auth_headers().await?; - if headers.contains_key(&self.name) { - return Err(AuthError::Build( - "gateway OAuth conflicts with primary auth headers".into(), - )); - } - headers.insert(self.name.clone(), self.value.clone()); - Ok(headers) - }) - } - fn apply_auth(&self, request: codex_http_client::Request) -> AuthProviderFuture<'_> { Box::pin(async move { let mut request = self.primary.apply_auth(request).await?; diff --git a/codex-rs/model-provider/src/shared_state_tests.rs b/codex-rs/model-provider/src/shared_state_tests.rs index 6039fbb77d66..a98a26269fae 100644 --- a/codex-rs/model-provider/src/shared_state_tests.rs +++ b/codex-rs/model-provider/src/shared_state_tests.rs @@ -134,7 +134,10 @@ async fn gateway_credentials_accompany_primary_auth_in_models_and_responses() { ); // WebSocket handshakes use the synchronous header interface. assert_eq!(auth.to_auth_headers().unwrap(), expected); - assert_eq!(auth.resolve_auth_headers().await.unwrap(), expected); + assert_eq!( + auth.apply_auth_headers(HeaderMap::new()).await.unwrap(), + expected + ); assert_eq!(scoped.auth.to_auth_headers().unwrap(), expected); assert!(auth.to_auth_headers().unwrap()[name].is_sensitive()); let transport = ReqwestTransport::from_http_client( diff --git a/codex-rs/runtime-agent-instance/src/auth.rs b/codex-rs/runtime-agent-instance/src/auth.rs index fbccda89a313..fbfc663b2e1a 100644 --- a/codex-rs/runtime-agent-instance/src/auth.rs +++ b/codex-rs/runtime-agent-instance/src/auth.rs @@ -120,7 +120,7 @@ impl AuthProvider for RuntimeRequestAuth { Box::pin(async move { Ok(replace_auth_headers( headers, - self.resolve_auth_headers().await?, + self.auth.auth_headers(self.mode)?, )) }) } diff --git a/codex-rs/runtime-agent-instance/src/manager_tests.rs b/codex-rs/runtime-agent-instance/src/manager_tests.rs index 06b2d8038c7e..64695f52b4dd 100644 --- a/codex-rs/runtime-agent-instance/src/manager_tests.rs +++ b/codex-rs/runtime-agent-instance/src/manager_tests.rs @@ -134,7 +134,7 @@ async fn concurrent_requests_share_registration_and_replacement_preserves_in_fli first .clone() .into_auth_provider(RuntimeAuthMode::DualHeader) - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .unwrap()["authorization"], "Bearer key-a" @@ -143,7 +143,7 @@ async fn concurrent_requests_share_registration_and_replacement_preserves_in_fli replacement .clone() .into_auth_provider(RuntimeAuthMode::DualHeader) - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .unwrap()["authorization"], "Bearer key-b" @@ -230,7 +230,7 @@ async fn policy_recovery_preserves_pending_registration() { let headers = selected .clone() .into_auth_provider(RuntimeAuthMode::DualHeader) - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .unwrap(); assert_eq!(headers["authorization"], "Bearer key-a"); @@ -450,14 +450,14 @@ async fn new_request_uses_refreshed_credentials_without_replacing_the_runtime() let provider = runtime .clone() .into_auth_provider(RuntimeAuthMode::DualHeader); - let first = provider.resolve_auth_headers().await.unwrap(); + let first = provider.apply_auth_headers(HeaderMap::new()).await.unwrap(); let refreshed = oauth(Some("login-a"), "new"); *source.current.write().unwrap() = refreshed.clone(); let next_runtime = manager.auth().await.unwrap(); let next = next_runtime .clone() .into_auth_provider(RuntimeAuthMode::DualHeader) - .resolve_auth_headers() + .apply_auth_headers(HeaderMap::new()) .await .unwrap(); assert_eq!( @@ -466,7 +466,7 @@ async fn new_request_uses_refreshed_credentials_without_replacing_the_runtime() ); assert_ne!(first[AUTHORIZATION], next[AUTHORIZATION]); assert_eq!( - provider.resolve_auth_headers().await.unwrap()[AUTHORIZATION], + provider.apply_auth_headers(HeaderMap::new()).await.unwrap()[AUTHORIZATION], first[AUTHORIZATION] ); assert!(Arc::ptr_eq(runtime.runtime(), next_runtime.runtime())); From 457441bca9af64315df39caea360676c2e9fd5d1 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 23 Oct 2026 20:28:34 +0000 Subject: [PATCH 015/287] Add constraint-aware standalone network proxy configuration (#57180) ## What changed - Export `StaticNetworkProxyConfig` to parse and serialize network policy with constraints, and validate them before building proxy state. - Add `--strict` and `from_slice_strict` for managed callers, requiring explicit `constraints` and rejecting unknown fields at every typed level. Default parsing permits top-level `metadata` and omitted constraints while still rejecting unknown policy fields. - Match allowed Unix socket paths case-sensitively when validating constraints. - Bound configuration file reads to the size limit plus one byte. ## Testing Add coverage for constraint round trips, weakened policy rejection, exact socket path matching, parsing modes, and CLI flag selection. Exercise automatic MITM enablement through both parsers. GitOrigin-RevId: c45d094624240f23dd4659128e72a4208d5d5f1c --- codex-rs/network-proxy/README.md | 8 +- codex-rs/network-proxy/src/config.rs | 4 + .../network-proxy/src/config/standalone.rs | 246 ++++++++++++++++++ codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/main.rs | 72 ++--- codex-rs/network-proxy/src/main_tests.rs | 102 +++----- codex-rs/network-proxy/src/state.rs | 10 +- 7 files changed, 319 insertions(+), 124 deletions(-) create mode 100644 codex-rs/network-proxy/src/config/standalone.rs diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6556281f73f1..d2809b5535af 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -110,13 +110,17 @@ standalone JSON file: `mitm` defaults to `false`. `mitm_ca` defaults to `None` and is omitted from serialized JSON when unset. HTTPS MITM is enabled automatically for limited mode or configured `mitm_hooks`. Set `mitm` to -`true` to enable it explicitly. The proxy requires `network.enabled = true` and rejects unknown -fields, including fields nested inside MITM hooks. +`true` to enable it explicitly. The proxy requires `network.enabled = true`. Unknown policy fields are rejected in both modes. +Standalone documents may include a top-level `metadata` value and omit `constraints`. ```bash cargo run -p codex-network-proxy -- --config /path/to/network-proxy.json ``` +Managed hosts must pass `--strict`, which rejects unknown fields at every typed level and requires +an explicit `constraints` object. Library callers use `StaticNetworkProxyConfig::from_slice_strict` +for the same checks. + ### 3) Point a client at it For HTTP(S) traffic: diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 91a201f3efe7..2db5eb7358fa 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -1,3 +1,7 @@ +mod standalone; + +pub use standalone::StaticNetworkProxyConfig; + use anyhow::Context; use anyhow::Result; use anyhow::bail; diff --git a/codex-rs/network-proxy/src/config/standalone.rs b/codex-rs/network-proxy/src/config/standalone.rs new file mode 100644 index 000000000000..67f53c48c94d --- /dev/null +++ b/codex-rs/network-proxy/src/config/standalone.rs @@ -0,0 +1,246 @@ +use super::NetworkMode; +use super::NetworkProxyConfig; +use crate::ConfigState; +use crate::NetworkProxyConstraints; +use crate::Platform; +use crate::build_config_state; +use crate::validate_policy_against_constraints; +use anyhow::Result; +use anyhow::ensure; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; + +/// A resolved policy, including the ceilings applied by managed requirements. +/// +/// Keep this document outside the workload's writable filesystem. It is not an +/// attestation of enforcement: the host must separately ensure that all workload +/// egress traverses the proxy and bind readiness to the current environment. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct StaticNetworkProxyConfig { + pub network: NetworkProxyConfig, + /// Standalone configurations may omit constraints. Managed callers must export + /// the resolved requirements and use `from_slice_strict` to require this field. + pub constraints: NetworkProxyConstraints, +} + +impl<'de> Deserialize<'de> for StaticNetworkProxyConfig { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Document { + network: NetworkProxyConfig, + #[serde(default)] + constraints: NetworkProxyConstraints, + } + + let config: Document = deserialize_document(deserializer, /*allow_metadata*/ true)?; + Ok(Self { + network: config.network, + constraints: config.constraints, + }) + } +} + +// Only the top-level metadata object is extensible. Ignoring a matcher key can widen access. +fn deserialize_document<'de, D, T>( + deserializer: D, + allow_metadata: bool, +) -> std::result::Result +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let mut unknown = None; + let config = serde_ignored::deserialize(deserializer, |path| { + let path = path.to_string(); + if unknown.is_none() && !(allow_metadata && path == "metadata") { + unknown = Some(path); + } + })?; + if let Some(path) = unknown { + return Err(serde::de::Error::custom(format!("unknown field: {path}"))); + } + Ok(config) +} + +impl StaticNetworkProxyConfig { + /// Parse standalone policy, allowing optional constraints and top-level metadata. + pub fn from_slice(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice::(bytes)?.normalize()) + } + + /// Require explicit constraints and reject unknown fields at every typed level. + pub fn from_slice_strict(bytes: &[u8]) -> Result { + #[derive(Deserialize)] + struct RequiredConstraints { + network: NetworkProxyConfig, + constraints: NetworkProxyConstraints, + } + + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let config: RequiredConstraints = + deserialize_document(&mut deserializer, /*allow_metadata*/ false)?; + deserializer.end()?; + Ok(Self { + network: config.network, + constraints: config.constraints, + } + .normalize()) + } + + fn normalize(mut self) -> Self { + self.network.mitm |= + self.network.mode == NetworkMode::Limited || !self.network.mitm_hooks.is_empty(); + self + } + + /// Validate before creating listeners or loading credential/CA material. + pub fn build_state(self, platform: Platform) -> Result { + let config = self.normalize(); + ensure!( + config.network.enabled, + "standalone network proxy requires network.enabled = true" + ); + validate_policy_against_constraints(&config.network, &config.constraints)?; + build_config_state(config.network, config.constraints, platform) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn static_config_preserves_constraints_and_rejects_weakened_policy() { + let bytes = serde_json::to_vec(&json!({ + "network": { + "enabled": true, + "mode": "limited", + "allow_upstream_proxy": false, + "domains": {"*.example.com": "allow", "blocked.example.com": "deny"} + }, + "constraints": { + "enabled": true, + "allow_upstream_proxy": false, + "allowed_domains": ["*.example.com"], + "allowlist_expansion_enabled": false, + "denied_domains": ["blocked.example.com"], + "denylist_expansion_enabled": true + } + })) + .unwrap(); + let config = StaticNetworkProxyConfig::from_slice(&bytes).unwrap(); + let round_trip = + StaticNetworkProxyConfig::from_slice(&serde_json::to_vec(&config).unwrap()).unwrap(); + assert_eq!(round_trip, config); + let direct: StaticNetworkProxyConfig = serde_json::from_slice(&bytes).unwrap(); + assert!(!direct.network.mitm); + let dir = tempfile::tempdir().unwrap(); + let ca = crate::certs::ManagedMitmCa::for_test(dir.path()).unwrap(); + let state = crate::certs::TEST_MITM_CA + .sync_scope(ca, || direct.build_state(Platform::Linux)) + .unwrap(); + assert!(state.mitm.is_some()); + assert_eq!(state.config, config.network); + assert_eq!(state.constraints, config.constraints); + + let mut weakened = config; + weakened.network.allow_upstream_proxy = true; + assert!(weakened.build_state(Platform::Linux).is_err()); + + for bytes in [ + br#"{"network":{"enabled":true},"constraints":{"enabled":false}}"#.as_slice(), + br#"{"network":{"enabled":false},"constraints":{}}"#.as_slice(), + ] { + assert!( + StaticNetworkProxyConfig::from_slice(bytes) + .unwrap() + .build_state(Platform::Linux) + .is_err() + ); + } + } + + #[test] + fn managed_socket_paths_match_exactly() { + let mut policy = StaticNetworkProxyConfig::from_slice_strict( + br#"{"network":{"enabled":true,"unix_sockets":{"/run/Secret.sock":"allow"}}, + "constraints":{"allow_unix_sockets":["/run/Secret.sock"]}}"#, + ) + .unwrap(); + assert!(validate_policy_against_constraints(&policy.network, &policy.constraints).is_ok()); + policy + .network + .set_allow_unix_sockets(vec!["/run/secret.sock".to_string()]); + assert!(validate_policy_against_constraints(&policy.network, &policy.constraints).is_err()); + } + + #[test] + fn parsing_modes_handle_constraints_and_unknown_fields() { + let without_constraints = br#"{"network":{"enabled":true}}"#; + assert!(StaticNetworkProxyConfig::from_slice(without_constraints).is_ok()); + assert!(StaticNetworkProxyConfig::from_slice_strict(without_constraints).is_err()); + + let baseline = json!({ + "network": { + "enabled": true, + "mitm_hooks": [{ + "host": "example.com", + "match": {"methods": ["POST"]}, + "actions": {"strip_request_headers": ["x-debug"]} + }] + }, + "constraints": {"allow_upstream_proxy": false} + }); + let expected = + StaticNetworkProxyConfig::from_slice_strict(&serde_json::to_vec(&baseline).unwrap()) + .unwrap(); + let mut annotated = baseline.clone(); + annotated["metadata"] = json!({"producer_version": 2}); + let bytes = serde_json::to_vec(&annotated).unwrap(); + assert_eq!( + StaticNetworkProxyConfig::from_slice(&bytes).unwrap(), + expected + ); + assert!(StaticNetworkProxyConfig::from_slice_strict(&bytes).is_err()); + + for pointer in [ + "", + "/network", + "/constraints", + "/network/mitm_hooks/0/match", + "/network/mitm_hooks/0/actions", + ] { + let mut config = baseline.clone(); + config.pointer_mut(pointer).unwrap()["future_setting"] = json!(true); + let bytes = serde_json::to_vec(&config).unwrap(); + for parse in [ + StaticNetworkProxyConfig::from_slice, + StaticNetworkProxyConfig::from_slice_strict, + ] { + let error = parse(&bytes).unwrap_err(); + assert!( + error.to_string().contains("future_setting"), + "{pointer}: {error}" + ); + } + } + + for bytes in [ + br#"{"constraints":{}}"#.as_slice(), + br#"{"network":{"mitm_hooks":[{"host":"example.com","match":{"header":{}}}]},"constraints":{}}"#, + br#"{"network":{},"constraint":{}}"#, + br#"{"network":{"enabled":"true"},"constraints":{}}"#, + br#"{"network":{},"constraints":null}"#, + br#"{"network":{},"constraints":{}} {}"#, + ] { + assert!(StaticNetworkProxyConfig::from_slice(bytes).is_err()); + assert!(StaticNetworkProxyConfig::from_slice_strict(bytes).is_err()); + } + } +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 30cd34bcb329..7e12ae9b905e 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -53,6 +53,7 @@ pub use config::NetworkMode; pub use config::NetworkProxyConfig; pub use config::NetworkUnixSocketPermission; pub use config::NetworkUnixSocketPermissions; +pub use config::StaticNetworkProxyConfig; pub use config::host_and_port_from_network_addr; pub use config::managed_proxy_ports; pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; diff --git a/codex-rs/network-proxy/src/main.rs b/codex-rs/network-proxy/src/main.rs index 0f6f9b765738..6ef5186153f2 100644 --- a/codex-rs/network-proxy/src/main.rs +++ b/codex-rs/network-proxy/src/main.rs @@ -7,14 +7,11 @@ use clap::Parser; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigReloaderFuture; use codex_network_proxy::ConfigState; -use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxy; -use codex_network_proxy::NetworkProxyConfig; -use codex_network_proxy::NetworkProxyConstraints; use codex_network_proxy::NetworkProxyState; use codex_network_proxy::Platform; -use codex_network_proxy::build_config_state; -use serde::Deserialize; +use codex_network_proxy::StaticNetworkProxyConfig; +use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use tracing_subscriber::EnvFilter; @@ -27,29 +24,19 @@ struct Args { /// Standalone JSON configuration containing a `network` object. #[arg(long, value_name = "PATH")] config: PathBuf, + /// Reject unknown config fields and require explicit managed constraints. + #[arg(long)] + strict: bool, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct StandaloneConfig { - network: NetworkProxyConfig, -} - -fn parse_standalone_config(bytes: &[u8]) -> Result { - let mut deserializer = serde_json::Deserializer::from_slice(bytes); - let mut ignored_fields = Vec::new(); - let mut config: StandaloneConfig = serde_ignored::deserialize(&mut deserializer, |path| { - ignored_fields.push(path.to_string()); - })?; - deserializer.end()?; - ensure!( - ignored_fields.is_empty(), - "unknown fields in network proxy config: {}", - ignored_fields.join(", ") - ); - config.network.mitm |= - config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty(); - Ok(config) +impl Args { + fn parse_config(&self, bytes: &[u8]) -> Result { + if self.strict { + StaticNetworkProxyConfig::from_slice_strict(bytes) + } else { + StaticNetworkProxyConfig::from_slice(bytes) + } + } } struct StaticConfigReloader { @@ -76,29 +63,26 @@ async fn main() -> Result<()> { .with_env_filter(EnvFilter::from_default_env()) .init(); let args = Args::parse(); - let bytes = std::fs::read(&args.config).with_context(|| { - format!( - "failed to read network proxy config: {}", - args.config.display() - ) - })?; + let mut bytes = Vec::new(); + std::fs::File::open(&args.config) + .with_context(|| { + format!( + "failed to read network proxy config: {}", + args.config.display() + ) + })? + .take((MAX_CONFIG_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; ensure!( bytes.len() <= MAX_CONFIG_BYTES, "network proxy config exceeds {MAX_CONFIG_BYTES} bytes" ); - let config = parse_standalone_config(&bytes) + let config = args + .parse_config(&bytes) .with_context(|| format!("invalid network proxy config: {}", args.config.display()))?; - ensure!( - config.network.enabled, - "standalone network proxy requires network.enabled = true" - ); - - let config_state = build_config_state( - config.network, - NetworkProxyConstraints::default(), - Platform::native(), - ) - .context("failed to initialize network proxy policy")?; + let config_state = config + .build_state(Platform::native()) + .context("failed to initialize network proxy policy")?; let reloader = Arc::new(StaticConfigReloader { state: config_state.clone(), }); diff --git a/codex-rs/network-proxy/src/main_tests.rs b/codex-rs/network-proxy/src/main_tests.rs index 61fa2d849e27..b0ed9a08e6a1 100644 --- a/codex-rs/network-proxy/src/main_tests.rs +++ b/codex-rs/network-proxy/src/main_tests.rs @@ -1,5 +1,6 @@ -use super::parse_standalone_config; +use clap::Parser; use codex_network_proxy::NetworkMitmCaConfig; +use codex_network_proxy::StaticNetworkProxyConfig; use pretty_assertions::assert_eq; use serde_json::json; @@ -37,12 +38,17 @@ fn enables_mitm_for_limited_mode_and_hooks() { }), ), ] { - let bytes = serde_json::to_vec(&json!({"network": network})).unwrap(); - let config = parse_standalone_config(&bytes).unwrap(); - assert_eq!( - (config.network.mitm, config.network.mitm_ca), - (expected_mitm, expected_ca) - ); + let bytes = serde_json::to_vec(&json!({"network": network, "constraints": {}})).unwrap(); + for parse in [ + StaticNetworkProxyConfig::from_slice, + StaticNetworkProxyConfig::from_slice_strict, + ] { + let config = parse(&bytes).unwrap(); + assert_eq!( + (config.network.mitm, config.network.mitm_ca), + (expected_mitm, expected_ca.clone()) + ); + } } } @@ -60,11 +66,12 @@ fn accepts_dynamic_domain_socket_and_hook_matcher_keys() { "headers": {"authorization": ["Bearer token"]} } }] - } + }, + "constraints": {} })) .unwrap(); - let config = parse_standalone_config(&bytes).unwrap(); + let config = StaticNetworkProxyConfig::from_slice_strict(&bytes).unwrap(); assert_eq!( config.network.allowed_domains(), @@ -77,69 +84,20 @@ fn accepts_dynamic_domain_socket_and_hook_matcher_keys() { } #[test] -fn rejects_unknown_fields_at_every_mitm_hook_level() { - for (network, unknown_field) in [ - ( - json!({"enabled": true, "unexpected_network": true}), - "unexpected_network", - ), - ( - json!({ - "enabled": true, - "mitm_hooks": [{ - "host": "api.example.com", - "unexpected_hook": true - }] - }), - "unexpected_hook", - ), - ( - json!({ - "enabled": true, - "mitm_hooks": [{ - "host": "api.example.com", - "match": {"unexpected_matcher": true} - }] - }), - "unexpected_matcher", - ), - ( - json!({ - "enabled": true, - "mitm_hooks": [{ - "host": "api.example.com", - "actions": {"unexpected_action": true} - }] - }), - "unexpected_action", - ), - ( - json!({ - "enabled": true, - "mitm_hooks": [{ - "host": "api.example.com", - "actions": { - "inject_request_headers": [{ - "name": "authorization", - "unexpected_header": true - }] - } - }] - }), - "unexpected_header", - ), +fn strict_flag_controls_config_parsing() { + let base = ["codex-network-proxy", "--config", "policy.json"]; + let lenient = super::Args::try_parse_from(base).unwrap(); + let strict = super::Args::try_parse_from(base.into_iter().chain(["--strict"])).unwrap(); + for bytes in [ + br#"{"network":{"enabled":true}}"#.as_slice(), + br#"{"network":{"enabled":true},"metadata":{"future_setting":true},"constraints":{}}"#, ] { - let bytes = serde_json::to_vec(&json!({"network": network})).unwrap(); - let error = parse_standalone_config(&bytes).unwrap_err(); - assert!( - error.to_string().contains(unknown_field), - "expected {unknown_field} in {error}" - ); + assert!(lenient.parse_config(bytes).is_ok()); + assert!(strict.parse_config(bytes).is_err()); } -} - -#[test] -fn rejects_trailing_json_values() { - let error = parse_standalone_config(br#"{"network":{"enabled":true}} {}"#).unwrap_err(); - assert!(error.to_string().contains("trailing characters")); + assert!( + strict + .parse_config(br#"{"network":{"enabled":true},"constraints":{}}"#) + .is_ok() + ); } diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 262b7270c239..fe94a577e319 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -13,6 +13,7 @@ use crate::policy::compile_denylist; use crate::policy::is_global_wildcard_domain_pattern; use crate::runtime::ConfigState; use serde::Deserialize; +use serde::Serialize; use std::collections::HashSet; use std::sync::Arc; @@ -23,7 +24,7 @@ pub use crate::runtime::NetworkProxyState; #[cfg(test)] pub(crate) use crate::runtime::network_proxy_state_for_policy; -#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct NetworkProxyConstraints { pub enabled: Option, pub mode: Option, @@ -383,14 +384,11 @@ pub fn validate_policy_against_constraints( } if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets { - let allowed_set: HashSet = allow_unix_sockets - .iter() - .map(|s| s.to_ascii_lowercase()) - .collect(); + let allowed_set: HashSet<&str> = allow_unix_sockets.iter().map(String::as_str).collect(); validate(config_allow_unix_sockets, move |candidate| { let mut invalid = Vec::new(); for entry in candidate { - if !allowed_set.contains(&entry.to_ascii_lowercase()) { + if !allowed_set.contains(entry.as_str()) { invalid.push(entry.clone()); } } From 8ebd272491ebe7626dfe4ae18f584c423b7b33ee Mon Sep 17 00:00:00 2001 From: Alex Kira Date: Fri, 23 Oct 2026 21:44:37 +0000 Subject: [PATCH 016/287] Add optional names to untrusted additional context (#57181) ## What changed - Add an optional `name` to `AdditionalContextEntry` across the protocol, app-server schemas, and generated clients. - Accept names matching `[a-z][a-z0-9_]{0,63}` only for `untrusted` context. Reject invalid names and names on `application` context before admitting turn input, returning invalid-request errors from `turn/start` and `turn/steer`. - Preserve existing model-input rendering and deduplicate context when only its name changes. ## Testing Add coverage for valid name boundaries, callers that omit `name`, invalid-name rejection without admitting input, and deduplication across name changes. GitOrigin-RevId: 2caffa6fddd40ddb178733b335f520f317239edd --- .../schema/json/ClientRequest.json | 7 + .../codex_app_server_protocol.schemas.json | 7 + .../codex_app_server_protocol.v2.schemas.json | 7 + .../schema/json/v2/TurnStartParams.json | 7 + .../schema/json/v2/TurnSteerParams.json | 7 + .../app-server-exports-experimental.json.zst | Bin 168429 -> 168556 bytes .../app-server-exports-stable.json.zst | Bin 161065 -> 161181 bytes .../typescript/v2/AdditionalContextEntry.ts | 6 +- codex-rs/app-server-protocol/src/export.rs | 4 +- .../src/protocol/v2/turn.rs | 3 + codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/turn_processor.rs | 41 +++-- .../tests/suite/v2/thread_inject_items.rs | 2 + .../app-server/tests/suite/v2/turn_start.rs | 103 ++++++++--- .../app-server/tests/suite/v2/turn_steer.rs | 37 ++++ codex-rs/core/src/session/turn_input.rs | 6 + codex-rs/core/src/state/additional_context.rs | 6 +- .../core/tests/suite/additional_context.rs | 169 ++++++++++++------ .../core/tests/suite/context_annotations.rs | 16 +- codex-rs/protocol/src/protocol.rs | 39 ++++ .../src/openai_codex/generated/v2_all.py | 4 + 21 files changed, 371 insertions(+), 102 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8702f522a449..3319da11fef9 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -17,6 +17,13 @@ "kind": { "$ref": "#/definitions/AdditionalContextKind" }, + "name": { + "description": "Optional name for untrusted context, used to label its tool output.", + "type": [ + "string", + "null" + ] + }, "value": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 669752dbfe02..c185563d7c1e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7034,6 +7034,13 @@ "kind": { "$ref": "#/definitions/v2/AdditionalContextKind" }, + "name": { + "description": "Optional name for untrusted context, used to label its tool output.", + "type": [ + "string", + "null" + ] + }, "value": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0ce520a0d4b4..a5663a8c0632 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -269,6 +269,13 @@ "kind": { "$ref": "#/definitions/AdditionalContextKind" }, + "name": { + "description": "Optional name for untrusted context, used to label its tool output.", + "type": [ + "string", + "null" + ] + }, "value": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index ad4194a51160..cdac11cbe73d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -10,6 +10,13 @@ "kind": { "$ref": "#/definitions/AdditionalContextKind" }, + "name": { + "description": "Optional name for untrusted context, used to label its tool output.", + "type": [ + "string", + "null" + ] + }, "value": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index 7dbdefa046ae..2fdbb54a1bcc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -6,6 +6,13 @@ "kind": { "$ref": "#/definitions/AdditionalContextKind" }, + "name": { + "description": "Optional name for untrusted context, used to label its tool output.", + "type": [ + "string", + "null" + ] + }, "value": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 3a60dfafeb2163ae72e4b2e49e9aeeea547c0a47..208990a25881a4e91aa506a392a4075ce7a2536f 100644 GIT binary patch delta 168554 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�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 delta 168409 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

w@B)_yM&cnq>mhKpS5S505()|d!^MOfA} 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%0DXpDE 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 diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 86df21737e80cd0624e4d251fc914da21505b652..498a398ae23ef340ea58db81a63cfab8ad3f03b3 100644 GIT binary patch delta 160335 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¬VjSV8E7s=lhVzL4h34S*F4ww} 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�q|v45(4kUpYJV=ZxbB<@ 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

B%n_P@ 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 z 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�k5xR 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 essLA=x, } /// Requested cyber treatment for an OpenAI model turn. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 3ebcd1d7eea8..835bf5c9d71d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -444,6 +444,8 @@ use codex_protocol::error::Result as CodexResult; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; +use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationSpeechParams; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index bc2f029dfaee..c78613c0ed3b 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -11,8 +11,6 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageReference as CoreImageReference; -use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; -use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; use codex_protocol::protocol::TurnEnvironmentRequests; use codex_protocol::protocol::TurnSettingsUpdate; use codex_protocol::protocol::TurnSettingsUpdateOutcome; @@ -104,18 +102,15 @@ fn map_additional_context( .unwrap_or_default() .into_iter() .map(|(key, entry)| { - ( - key, - CoreAdditionalContextEntry { - value: entry.value, - kind: match entry.kind { - AdditionalContextKind::Untrusted => CoreAdditionalContextKind::Untrusted, - AdditionalContextKind::Application => { - CoreAdditionalContextKind::Application - } - }, + let entry = CoreAdditionalContextEntry { + value: entry.value, + kind: match entry.kind { + AdditionalContextKind::Untrusted => CoreAdditionalContextKind::Untrusted, + AdditionalContextKind::Application => CoreAdditionalContextKind::Application, }, - ) + name: entry.name, + }; + (key, entry) }) .collect() } @@ -581,6 +576,15 @@ impl TurnRequestProcessor { ); return Err(error); } + // Validate before changing the thread's live MCP policy. + let additional_context = map_additional_context(params.additional_context); + additional_context + .values() + .try_for_each(CoreAdditionalContextEntry::validate) + .map_err(invalid_request) + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; Self::set_app_server_client_info( thread.as_ref(), app_server_client_name, @@ -596,7 +600,6 @@ impl TurnRequestProcessor { let environment_requests = resolve_turn_environment_requests(self.thread_manager.as_ref(), params.environments)?; - let additional_context = map_additional_context(params.additional_context); let turn_has_input = !params.input.is_empty(); let input = if let Some(tool_output) = params.tool_output { let item = ResponseItem::FunctionCallOutput { @@ -679,7 +682,10 @@ impl TurnRequestProcessor { ) .await .map_err(|err| { - let error = internal_error(format!("failed to submit turn input: {err}")); + let error = match err.details() { + CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()), + _ => internal_error(format!("failed to submit turn input: {err}")), + }; self.track_error_response(&request_id, &error, /*error_type*/ None); error })?; @@ -1096,7 +1102,10 @@ impl TurnRequestProcessor { ) .await .map_err(|err| { - let error = internal_error(format!("failed to steer turn: {err}")); + let error = match err.details() { + CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()), + _ => internal_error(format!("failed to steer turn: {err}")), + }; self.track_error_response(request_id, &error, /*error_type*/ None); error })?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 70aa592be2c8..931c45e85676 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -196,6 +196,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history( AdditionalContextEntry { value: application_context_text.to_string(), kind: AdditionalContextKind::Application, + name: None, }, ), ( @@ -203,6 +204,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history( AdditionalContextEntry { value: untrusted_context_text.to_string(), kind: AdditionalContextKind::Untrusted, + name: None, }, ), ])), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 0f8b25c9e858..748f0a617132 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -790,8 +790,13 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<() Ok(()) } +#[test_case::test_case(Some("browser"); "named_context")] +#[test_case::test_case(Some("a"); "minimum_length")] +#[test_case::test_case(Some("browser_2"); "digits_and_underscore")] +#[test_case::test_case(Some(&"a".repeat(64)); "maximum_length")] +#[test_case::test_case(None; "legacy_caller")] #[tokio::test] -async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { +async fn turn_start_additional_context_flows_to_model_input(name: Option<&str>) -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; let server = create_mock_responses_server_sequence_unchecked(responses).await; @@ -810,27 +815,75 @@ async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { }) .await?; - let _: TurnStartResponse = mcp - .request(|request_id| ClientRequest::TurnStart { - request_id, - params: TurnStartParams { - thread_id: thread.id, - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "inspect tab".to_string(), - text_elements: Vec::new(), - }], - additional_context: Some(HashMap::from([( - "custom_source".to_string(), - AdditionalContextEntry { - value: "source value".to_string(), - kind: AdditionalContextKind::Untrusted, - }, - )])), - ..Default::default() - }, + let rejected_request = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "rejected input".to_owned(), + text_elements: Vec::new(), + }], + additional_context: Some(HashMap::from([( + "application".to_owned(), + AdditionalContextEntry { + value: "rejected context".to_owned(), + kind: AdditionalContextKind::Application, + name: Some("browser".to_owned()), + }, + )])), + ..Default::default() }) .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(rejected_request)), + ) + .await??; + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + + let mut params = serde_json::to_value(TurnStartParams { + thread_id: thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "inspect tab".to_string(), + text_elements: Vec::new(), + }], + additional_context: Some(HashMap::from([( + "custom_source".to_string(), + AdditionalContextEntry { + value: "source value".to_string(), + kind: AdditionalContextKind::Untrusted, + name: name.map(str::to_owned), + }, + )])), + ..Default::default() + })?; + let entry = params["additionalContext"]["custom_source"] + .as_object_mut() + .context("additional context entry")?; + if name.is_none() { + entry.remove("name"); + } + let mut malformed_params = params.clone(); + malformed_params["additionalContext"]["custom_source"]["name"] = json!("agent.browser"); + let rejected_request = mcp + .send_request("turn/start", Some(malformed_params)) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(rejected_request)), + ) + .await??; + assert_eq!( + error.error, + codex_app_server_protocol::JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "additional context name must match [a-z][a-z0-9_]{0,63}".to_owned(), + data: None, + } + ); + + let request_id = mcp.send_request("turn/start", Some(params)).await?; + let _: TurnStartResponse = mcp.read_response(request_id).await?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), @@ -848,10 +901,20 @@ async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { let body = request .body_json::() .context("request body should be JSON")?; + let input = body["input"].as_array().context("input array")?; + let texts = input + .iter() + .filter_map(|item| item["content"].as_array()) + .flatten() + .filter_map(|part| part["text"].as_str()) + .collect::>(); + assert!(!texts.contains(&"rejected input")); + assert!(!texts.contains(&"rejected context")); assert!( body.to_string() .contains("source value") ); + assert!(!body.to_string().contains("function_call_output")); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 9d4966a44aaf..c6a677ec978a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::skip_if_remote; +use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; use tempfile::TempDir; @@ -99,6 +100,41 @@ async fn turn_steer_requires_active_turn() -> Result<()> { ); assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn"); + let malformed_request = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "rejected input".to_owned(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + responsesapi_client_metadata_patch: None, + additional_context: Some(HashMap::from([( + "browser_info".to_owned(), + AdditionalContextEntry { + value: "rejected context".to_owned(), + kind: AdditionalContextKind::Untrusted, + name: Some("agent.browser".to_owned()), + }, + )])), + expected_turn_id: "turn-does-not-exist".to_owned(), + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(malformed_request)), + ) + .await??; + assert_eq!( + error.error, + codex_app_server_protocol::JSONRPCErrorError { + code: -32600, + message: "additional context name must match [a-z][a-z0-9_]{0,63}".to_owned(), + data: None, + } + ); + Ok(()) } @@ -430,6 +466,7 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu AdditionalContextEntry { value: "tab one".to_string(), kind: AdditionalContextKind::Untrusted, + name: None, }, )])); let steer_req = mcp diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs index 8935c1c3109b..3b26bd9e8c3f 100644 --- a/codex-rs/core/src/session/turn_input.rs +++ b/codex-rs/core/src/session/turn_input.rs @@ -235,6 +235,12 @@ pub(super) async fn handle( submission_id: String, ) -> CodexResult { let request = request.into(); + request + .request + .additional_context + .values() + .try_for_each(AdditionalContextEntry::validate) + .map_err(|message| CodexErr::InvalidRequest(message.to_owned()))?; let result = match mode { TurnInputMode::StartOrSteer => start_or_steer(session, request, submission_id).await, TurnInputMode::StartIfIdle => { diff --git a/codex-rs/core/src/state/additional_context.rs b/codex-rs/core/src/state/additional_context.rs index 83144f8b9bdb..2ab78d54da2d 100644 --- a/codex-rs/core/src/state/additional_context.rs +++ b/codex-rs/core/src/state/additional_context.rs @@ -19,7 +19,11 @@ impl AdditionalContextStore { ) -> Vec { let fragments = values .iter() - .filter(|(key, value)| self.values.get(*key) != Some(*value)) + .filter(|(key, entry)| { + self.values.get(*key).is_none_or(|previous| { + previous.kind != entry.kind || previous.value != entry.value + }) + }) .map(|(key, entry)| match entry.kind { AdditionalContextKind::Untrusted => ContextualUserFragment::into( AdditionalContextUserFragment::new(key.clone(), entry.value.clone()), diff --git a/codex-rs/core/tests/suite/additional_context.rs b/codex-rs/core/tests/suite/additional_context.rs index 3fcb7d809dc9..e38c74d37bce 100644 --- a/codex-rs/core/tests/suite/additional_context.rs +++ b/codex-rs/core/tests/suite/additional_context.rs @@ -1,5 +1,6 @@ use anyhow::Result; use codex_core::TurnInputRequest; +use codex_protocol::error::CodexErrorDetails; use codex_protocol::items::TurnItem; use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind; @@ -46,17 +47,17 @@ async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Re .with_additional_context(BTreeMap::from([ ( "browser_info".to_string(), - AdditionalContextEntry { - value: "tab one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "tab one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ( "automation_info".to_string(), - AdditionalContextEntry { - value: "run one".to_string(), - kind: AdditionalContextKind::Application, - }, + AdditionalContextEntry::new( + "run one".to_string(), + AdditionalContextKind::Application, + ), ), ])), ) @@ -181,17 +182,17 @@ async fn additional_context_trust_controls_message_role() -> Result<()> { .with_additional_context(BTreeMap::from([ ( "browser_info".to_string(), - AdditionalContextEntry { - value: "tab one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "tab one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ( "automation_info".to_string(), - AdditionalContextEntry { - value: "run one".to_string(), - kind: AdditionalContextKind::Application, - }, + AdditionalContextEntry::new( + "run one".to_string(), + AdditionalContextKind::Application, + ), ), ])), ) @@ -222,8 +223,13 @@ async fn additional_context_trust_controls_message_role() -> Result<()> { Ok(()) } +#[test_case::test_case(None, None; "unchanged")] +#[test_case::test_case(Some("browser"), Some("editor"); "name_only_change")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn additional_context_is_deduplicated_between_turns_while_retained() -> Result<()> { +async fn additional_context_is_deduplicated_between_turns_while_retained( + first_name: Option<&str>, + second_name: Option<&str>, +) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -241,11 +247,12 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re .with_config(|config| config.include_environment_context = false) .build(&server) .await?; - let additional_context = BTreeMap::from([( + let mut additional_context = BTreeMap::from([( "browser_info".to_string(), AdditionalContextEntry { value: "same tab".to_string(), kind: AdditionalContextKind::Untrusted, + name: first_name.map(str::to_owned), }, )]); @@ -263,6 +270,10 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re }) .await; + additional_context + .get_mut("browser_info") + .expect("browser context") + .name = second_name.map(str::to_owned); test.codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { @@ -330,17 +341,17 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), - AdditionalContextEntry { - value: "run one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "run one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ( "browser_info".to_string(), - AdditionalContextEntry { - value: "tab one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "tab one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ])), ) @@ -359,17 +370,17 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), - AdditionalContextEntry { - value: "run one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "run one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ( "terminal_info".to_string(), - AdditionalContextEntry { - value: "pty one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "pty one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ])), ) @@ -388,24 +399,24 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), - AdditionalContextEntry { - value: "run one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "run one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ( "browser_info".to_string(), - AdditionalContextEntry { - value: "tab one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "tab one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ( "terminal_info".to_string(), - AdditionalContextEntry { - value: "pty one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "pty one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ])), ) @@ -481,17 +492,17 @@ async fn additional_context_values_are_truncated_before_model_input() -> Result< .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), - AdditionalContextEntry { - value: long_automation_value.clone(), - kind: AdditionalContextKind::Application, - }, + AdditionalContextEntry::new( + long_automation_value.clone(), + AdditionalContextKind::Application, + ), ), ( "browser_info".to_string(), - AdditionalContextEntry { - value: long_browser_value.clone(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + long_browser_value.clone(), + AdditionalContextKind::Untrusted, + ), ), ])), ) @@ -543,3 +554,53 @@ async fn additional_context_values_are_truncated_before_model_input() -> Result< Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn additional_context_rejects_invalid_names_before_admitting_input() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let request = mount_sse_once(&server, sse(vec![ev_completed("valid-turn")])).await; + let test = test_codex() + .with_config(|config| config.include_environment_context = false) + .build_with_auto_env(&server) + .await?; + + for (kind, name) in [ + (AdditionalContextKind::Untrusted, String::new()), + (AdditionalContextKind::Untrusted, "agent.browser".to_owned()), + (AdditionalContextKind::Untrusted, "other.browser".to_owned()), + (AdditionalContextKind::Untrusted, "1browser".to_owned()), + (AdditionalContextKind::Untrusted, "Browser".to_owned()), + (AdditionalContextKind::Untrusted, "é".to_owned()), + (AdditionalContextKind::Untrusted, "a".repeat(65)), + (AdditionalContextKind::Application, "browser".to_owned()), + ] { + let result = test + .codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "rejected input".to_owned(), + text_elements: Vec::new(), + }]) + .with_additional_context(BTreeMap::from([( + "source".to_owned(), + AdditionalContextEntry::new("rejected context".to_owned(), kind) + .with_name(name), + )])), + ) + .await; + assert!(matches!( + result + .expect_err("invalid context must be rejected") + .details(), + CodexErrorDetails::InvalidRequest(_) + )); + } + assert!(request.requests().is_empty()); + test.submit_text_turn("valid input").await?; + assert_eq!( + request.single_request().message_input_texts("user"), + vec!["valid input"] + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/context_annotations.rs b/codex-rs/core/tests/suite/context_annotations.rs index a5c606a0cc9d..d078c9bedf86 100644 --- a/codex-rs/core/tests/suite/context_annotations.rs +++ b/codex-rs/core/tests/suite/context_annotations.rs @@ -125,17 +125,17 @@ async fn first_request_item_types_roles_and_content_annotations() -> Result<()> .with_additional_context(BTreeMap::from([ ( "browser_info".to_string(), - AdditionalContextEntry { - value: "tab one".to_string(), - kind: AdditionalContextKind::Untrusted, - }, + AdditionalContextEntry::new( + "tab one".to_string(), + AdditionalContextKind::Untrusted, + ), ), ( "automation_info".to_string(), - AdditionalContextEntry { - value: "run one".to_string(), - kind: AdditionalContextKind::Application, - }, + AdditionalContextEntry::new( + "run one".to_string(), + AdditionalContextKind::Application, + ), ), ])), ) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 586dd6f0fe90..ffe6f1c0c896 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -646,6 +646,45 @@ pub enum AdditionalContextKind { pub struct AdditionalContextEntry { pub value: String, pub kind: AdditionalContextKind, + /// Optional name for untrusted context, used to label its tool output. + pub name: Option, +} + +impl AdditionalContextEntry { + /// Creates an unnamed context entry. + pub fn new(value: String, kind: AdditionalContextKind) -> Self { + Self { + value, + kind, + name: None, + } + } + + /// Sets the name for untrusted context. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Validates the optional untrusted-context name before input admission. + pub fn validate(&self) -> Result<(), &'static str> { + let Some(name) = &self.name else { + return Ok(()); + }; + if self.kind == AdditionalContextKind::Application { + return Err("additional context name is only supported for untrusted context"); + } + if name.is_empty() + || name.len() > 64 + || !name.as_bytes()[0].is_ascii_lowercase() + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err("additional context name must match [a-z][a-z0-9_]{0,63}"); + } + Ok(()) + } } /// Submission operation diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 3af00d6150c2..fcd8484fedad 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -6901,6 +6901,10 @@ class AdditionalContextEntry(BaseModel): populate_by_name=True, ) kind: AdditionalContextKind + name: Annotated[ + str | None, + Field(description="Optional name for untrusted context, used to label its tool output."), + ] = None value: str From 090bea7b7119988be75831a791edb2f6c5d23abd Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 23 Oct 2026 22:01:52 +0000 Subject: [PATCH 017/287] Centralize unified exec output rendering (#57185) ## What changed Introduce `CapturedStream` to keep retained output text and omitted-byte counts together. Use its shared renderer for unified exec logs and model-facing output, preserving omission markers and truncation notices. GitOrigin-RevId: 045d925df1e31945755196dafed5f78ed507b33b --- codex-rs/core/src/tools/context.rs | 52 +++------------ codex-rs/core/src/tools/context_tests.rs | 27 ++++---- .../handlers/unified_exec/exec_command.rs | 13 ++-- .../src/tools/handlers/unified_exec_tests.rs | 18 ++---- codex-rs/core/src/unified_exec/mod.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 6 +- codex-rs/core/src/unified_exec/output.rs | 63 +++++++++++++++++++ .../core/src/unified_exec/process_manager.rs | 10 ++- 8 files changed, 106 insertions(+), 84 deletions(-) create mode 100644 codex-rs/core/src/unified_exec/output.rs diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 6a58a9c3733b..474fa619181b 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -4,6 +4,7 @@ use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; use crate::turn_diff_tracker::TurnDiffTracker; +use crate::unified_exec::output::CapturedStream; use crate::unified_exec::resolve_max_tokens; use codex_protocol::ResponseItemId; use codex_protocol::mcp::CallToolResult; @@ -16,16 +17,11 @@ use codex_protocol::models::function_call_output_content_items_to_text; use codex_tools::LoadableToolSpec; use codex_tools::ToolName; use codex_utils_audio::estimate_audio_token_count; -use codex_utils_head_tail_buffer::format_output_omission_marker; use codex_utils_output_truncation::TruncationPolicy; -use codex_utils_output_truncation::approx_token_count; -use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_output_truncation::truncate_function_output_payload; -use codex_utils_output_truncation::truncate_text; use codex_utils_output_truncation::with_serialization_allowance; use serde::Serialize; use serde_json::Value as JsonValue; -use std::num::NonZeroUsize; use std::sync::Arc; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; @@ -425,28 +421,22 @@ pub struct ExecCommandToolOutput { pub event_call_id: String, pub chunk_id: String, pub wall_time: Duration, - /// Raw bytes returned for this unified exec call before any truncation. - pub raw_output: Vec, + /// Output retained by collection, before model-facing truncation. + pub(crate) raw_output: CapturedStream, pub truncation_policy: TruncationPolicy, pub max_output_tokens: Option, pub process_id: Option, pub exit_code: Option, pub original_token_count: Option, - /// Bytes omitted by the output collection cap before model-facing truncation. - pub output_omitted_bytes: Option, pub hook_command: Option, } impl ToolOutput for ExecCommandToolOutput { fn log_output(&self) -> String { // The telemetry budget must not inherit the model's output-token limit. - let mut output = String::from_utf8_lossy(&self.raw_output).into_owned(); - if let Some(omitted_bytes) = self.output_omitted_bytes { - let marker = format_output_omission_marker(omitted_bytes.get()); - if !output.contains(&marker) { - output = format!("{marker}\n{output}"); - } - } + let output = self + .raw_output + .render(/*policy*/ None, self.original_token_count); format!("{}\n{output}", self.response_header()) } @@ -512,7 +502,7 @@ impl ToolOutput for ExecCommandToolOutput { original_token_count: self.original_token_count, output: match self.max_output_tokens { Some(max_tokens) => self.truncated_output(max_tokens), - None => String::from_utf8_lossy(&self.raw_output).to_string(), + None => self.raw_output.text.clone(), }, }; @@ -537,32 +527,8 @@ impl ExecCommandToolOutput { } fn truncated_output_with_policy(&self, policy: TruncationPolicy) -> String { - let text = String::from_utf8_lossy(&self.raw_output).to_string(); - let Some(omitted_bytes) = self.output_omitted_bytes else { - return formatted_truncate_text(&text, policy); - }; - - let marker = format_output_omission_marker(omitted_bytes.get()); - if text.len() <= policy.byte_budget() { - return if text.contains(&marker) { - text - } else { - format!("{marker}\n{text}") - }; - } - - let original_token_count = self - .original_token_count - .unwrap_or_else(|| approx_token_count(&text)); - let truncated = truncate_text(&text, policy); - let omission_notice = if truncated.contains(&marker) { - String::new() - } else { - format!("{marker}\n") - }; - format!( - "Warning: truncated output (original token count: {original_token_count})\n{omission_notice}\n{truncated}" - ) + self.raw_output + .render(Some(policy), self.original_token_count) } fn response_header(&self) -> String { diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index bd0cb0c5b57b..7781ddccd9a9 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -1,7 +1,9 @@ use super::*; +use crate::unified_exec::output::CapturedStream; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::ImageReference; use codex_protocol::models::SearchToolCallParams; +use codex_utils_head_tail_buffer::format_output_omission_marker; use core_test_support::assert_regex_match; use pretty_assertions::assert_eq; use serde_json::json; @@ -416,13 +418,12 @@ fn exec_command_tool_output_formats_truncated_response() { event_call_id: "call-42".to_string(), chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(1250), - raw_output: b"token one token two token three token four token five".to_vec(), + raw_output: "token one token two token three token four token five".into(), truncation_policy: TruncationPolicy::Tokens(10_000), max_output_tokens: Some(4), process_id: None, exit_code: Some(0), original_token_count: Some(10), - output_omitted_bytes: None, hook_command: None, }; assert_eq!( @@ -462,8 +463,7 @@ fn exec_command_tool_output_reserves_metadata_budget_and_preserves_policy_units( }; let raw_output = (1..=150) .map(|line| format!("{line}\n")) - .collect::() - .into_bytes(); + .collect::(); for (policy, marker) in [ (TruncationPolicy::Bytes(200), "chars truncated"), @@ -473,13 +473,12 @@ fn exec_command_tool_output_reserves_metadata_budget_and_preserves_policy_units( event_call_id: "call-42".to_string(), chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(/*millis*/ 1250), - raw_output: raw_output.clone(), + raw_output: raw_output.as_str().into(), truncation_policy: policy, max_output_tokens: None, process_id: None, exit_code: Some(0), original_token_count: Some(123), - output_omitted_bytes: None, hook_command: None, } .to_response_item("call-42", &payload); @@ -511,33 +510,31 @@ fn exec_command_tool_output_preserves_omission_metadata_when_truncated() { "HEAD-{}\n{marker}\nTAIL-{}", "a".repeat(/*n*/ 100), "z".repeat(/*n*/ 100) - ) - .into_bytes(); + ); let mut output = ExecCommandToolOutput { event_call_id: "call-omitted".to_string(), chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(/*millis*/ 1250), - raw_output, + raw_output: CapturedStream { + text: raw_output.clone(), + omitted_bytes: 123_456, + }, truncation_policy: TruncationPolicy::Tokens(10_000), max_output_tokens: Some(4), process_id: None, exit_code: Some(0), original_token_count: Some(42_000), - output_omitted_bytes: NonZeroUsize::new(/*n*/ 123_456), hook_command: None, }; let expected_header = "Chunk ID: abc123\nWall time: 1.2500 seconds\nProcess exited with code 0\nOriginal token count: 42000\nOutput:\n"; assert_eq!( output.log_output(), - format!( - "{expected_header}{}", - String::from_utf8_lossy(&output.raw_output) - ) + format!("{expected_header}{raw_output}") ); let response = output.to_response_item("call-omitted", &payload); // Collection may report omitted bytes without including the marker in its text. - output.raw_output = b"remaining output".to_vec(); + output.raw_output.text = "remaining output".to_string(); assert_eq!( output.log_output(), format!("{expected_header}{marker}\nremaining output") diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index ca15be3f73c5..1a188c90a1cd 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -34,6 +34,7 @@ use crate::unified_exec::UnifiedExecContext; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcessManager; use crate::unified_exec::generate_chunk_id; +use crate::unified_exec::output::CapturedStream; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; @@ -374,13 +375,15 @@ impl ExecCommandHandler { event_call_id: String::new(), chunk_id: String::new(), wall_time: std::time::Duration::ZERO, - raw_output: output.into_text().into_bytes(), + raw_output: CapturedStream { + text: output.into_text(), + omitted_bytes: 0, + }, truncation_policy: step_context.settings.model_info.truncation_policy.into(), max_output_tokens, process_id: None, exit_code: None, original_token_count: None, - output_omitted_bytes: None, hook_command: None, })); } @@ -438,7 +441,10 @@ impl ExecCommandHandler { event_call_id: context.call_id.clone(), chunk_id: generate_chunk_id(), wall_time: output.duration, - raw_output: output_text.into_bytes(), + raw_output: CapturedStream { + text: output_text, + omitted_bytes: output_omitted_bytes.map_or(0, std::num::NonZeroUsize::get), + }, truncation_policy: step_context.settings.model_info.truncation_policy.into(), max_output_tokens, // Sandbox denial is terminal, so there is no live @@ -446,7 +452,6 @@ impl ExecCommandHandler { process_id: None, exit_code: Some(output.exit_code), original_token_count: Some(original_token_count), - output_omitted_bytes, hook_command: Some(hook_command), })) } diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 0881f7eafffc..ee8b481bba9e 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -524,13 +524,12 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s event_call_id: "call-43".to_string(), chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), - raw_output: b"three".to_vec(), + raw_output: "three".into(), truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), original_token_count: None, - output_omitted_bytes: None, hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-43", payload).await; @@ -555,13 +554,12 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi event_call_id: "call-44".to_string(), chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), - raw_output: b"three".to_vec(), + raw_output: "three".into(), truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), original_token_count: None, - output_omitted_bytes: None, hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-44", payload).await; @@ -587,13 +585,12 @@ async fn exec_command_post_tool_use_payload_skips_running_sessions() { event_call_id: "event-45".to_string(), chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), - raw_output: b"three".to_vec(), + raw_output: "three".into(), truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: Some(45), exit_code: None, original_token_count: None, - output_omitted_bytes: None, hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-45", payload).await; @@ -614,13 +611,12 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman event_call_id: "exec-call-45".to_string(), chunk_id: "chunk-2".to_string(), wall_time: std::time::Duration::from_millis(498), - raw_output: b"finished\n".to_vec(), + raw_output: "finished\n".into(), truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), original_token_count: None, - output_omitted_bytes: None, hook_command: Some("sleep 1; echo finished".to_string()), }; let invocation = invocation_for_payload("write_stdin", "write-stdin-call", payload).await; @@ -646,26 +642,24 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ event_call_id: "exec-call-a".to_string(), chunk_id: "chunk-a".to_string(), wall_time: std::time::Duration::from_millis(498), - raw_output: b"alpha\n".to_vec(), + raw_output: "alpha\n".into(), truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), original_token_count: None, - output_omitted_bytes: None, hook_command: Some("sleep 2; echo alpha".to_string()), }; let output_b = ExecCommandToolOutput { event_call_id: "exec-call-b".to_string(), chunk_id: "chunk-b".to_string(), wall_time: std::time::Duration::from_millis(498), - raw_output: b"beta\n".to_vec(), + raw_output: "beta\n".into(), truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), original_token_count: None, - output_omitted_bytes: None, hook_command: Some("sleep 1; echo beta".to_string()), }; let invocation_b = invocation_for_payload("write_stdin", "write-call-b", payload.clone()).await; diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index a7a8899c5e1b..26a0cc5258d6 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -49,6 +49,7 @@ use codex_core_plugins::PluginMetricsSidecar; mod async_watcher; mod errors; mod oneshot; +pub(crate) mod output; mod process; mod process_manager; pub(crate) use process_manager::exec_env_policy_from_shell_policy; diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 5fb49dbd257c..6be0a6ec6a5b 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -9,6 +9,7 @@ use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnContext; use crate::tools::context::ExecCommandToolOutput; use crate::unified_exec::WriteStdinRequest; +use crate::unified_exec::output::CapturedStream; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEventReceiver; use codex_exec_server::ExecProcessFuture; @@ -27,7 +28,6 @@ use core_test_support::skip_if_sandbox; use core_test_support::test_codex::test_env as remote_test_env; use pretty_assertions::assert_eq; use std::collections::HashMap; -use std::num::NonZeroUsize; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Notify; @@ -179,8 +179,7 @@ async fn exec_command_with_tty( collected_output.total_bytes(), )) .unwrap_or(usize::MAX); - let output_omitted_bytes = NonZeroUsize::new(collected_output.omitted_bytes()); - let collected = collected_output.to_bytes_with_omission_marker(); + let collected = CapturedStream::from(&collected_output); let has_exited = process.has_exited(); let exit_code = process.exit_code(); let response_process_id = if process_started_alive && !has_exited { @@ -212,7 +211,6 @@ async fn exec_command_with_tty( process_id: response_process_id, exit_code, original_token_count: Some(original_token_count), - output_omitted_bytes, hook_command: Some(cmd.to_string()), }) } diff --git a/codex-rs/core/src/unified_exec/output.rs b/codex-rs/core/src/unified_exec/output.rs new file mode 100644 index 000000000000..d035ca0ba99b --- /dev/null +++ b/codex-rs/core/src/unified_exec/output.rs @@ -0,0 +1,63 @@ +//! Text retained for one unified exec output stream and the bytes omitted by its collection cap. + +use codex_utils_head_tail_buffer::HeadTailBuffer; +use codex_utils_head_tail_buffer::format_output_omission_marker; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::formatted_truncate_text; +use codex_utils_output_truncation::truncate_text; + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct CapturedStream { + pub(crate) text: String, + pub(crate) omitted_bytes: usize, +} + +impl From<&str> for CapturedStream { + fn from(text: &str) -> Self { + Self { + text: text.to_string(), + omitted_bytes: 0, + } + } +} + +impl From<&HeadTailBuffer> for CapturedStream { + fn from(buffer: &HeadTailBuffer) -> Self { + Self { + text: String::from_utf8_lossy(&buffer.to_bytes_with_omission_marker()).into_owned(), + omitted_bytes: buffer.omitted_bytes(), + } + } +} + +impl CapturedStream { + pub(crate) fn render( + &self, + policy: Option, + original_tokens: Option, + ) -> String { + let truncated = match policy { + Some(policy) if self.omitted_bytes == 0 => { + return formatted_truncate_text(&self.text, policy); + } + Some(policy) => truncate_text(&self.text, policy), + None if self.omitted_bytes == 0 => return self.text.clone(), + None => self.text.clone(), + }; + let marker = format_output_omission_marker(self.omitted_bytes); + let notice = if truncated.contains(&marker) { + String::new() + } else { + format!("{marker}\n") + }; + if policy.is_none_or(|policy| self.text.len() <= policy.byte_budget()) { + if notice.is_empty() { + return truncated; + } + return format!("{notice}{truncated}"); + } + let tokens = original_tokens.unwrap_or_else(|| approx_token_count(&self.text)); + format!("Warning: truncated output (original token count: {tokens})\n{notice}\n{truncated}") + } +} diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index b07b410be643..b55848d9b180 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1,3 +1,4 @@ +use crate::unified_exec::output::CapturedStream; use rand::Rng; use std::cmp::Reverse; use std::collections::HashMap; @@ -686,8 +687,8 @@ impl UnifiedExecProcessManager { )) .unwrap_or(usize::MAX); let output_omitted_bytes = NonZeroUsize::new(collected_output.omitted_bytes()); - let collected = collected_output.to_bytes_with_omission_marker(); - let text = String::from_utf8_lossy(&collected).to_string(); + let collected = CapturedStream::from(&collected_output); + let text = collected.text.clone(); let chunk_id = generate_chunk_id(); if deferred_network_approval .as_ref() @@ -872,7 +873,6 @@ impl UnifiedExecProcessManager { process_id: response_process_id, exit_code, original_token_count: Some(original_token_count), - output_omitted_bytes, hook_command: Some(request.hook_command.clone()), }; @@ -1061,8 +1061,7 @@ impl UnifiedExecProcessManager { collected_output.total_bytes(), )) .unwrap_or(usize::MAX); - let output_omitted_bytes = NonZeroUsize::new(collected_output.omitted_bytes()); - let collected = collected_output.to_bytes_with_omission_marker(); + let collected = CapturedStream::from(&collected_output); let chunk_id = generate_chunk_id(); if network_approval .as_ref() @@ -1126,7 +1125,6 @@ impl UnifiedExecProcessManager { process_id, exit_code, original_token_count: Some(original_token_count), - output_omitted_bytes, hook_command: Some(hook_command), }; From af2e52c858cf7b16157e945725fd84bd6b90399a Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 23 Oct 2026 22:18:25 +0000 Subject: [PATCH 018/287] Fail closed on unreadable glob search root metadata errors (#57186) ## Why Linux sandbox deny-glob expansion silently skipped search roots when metadata lookup failed, potentially discarding a deny rule. ## What changed Return a fatal error with the search root and underlying error when metadata lookup fails, except for missing or non-directory roots, which still have no matches. Continue following directory symlinks. ## Testing Add regression tests for symlink-loop metadata errors stopping expansion before scanning, absent and non-directory roots being skipped, and symlink roots expanding to both logical and canonical paths without ripgrep. GitOrigin-RevId: bd97804ec5b599376c525addaacce6b1f29f9895 --- codex-rs/linux-sandbox/src/bwrap.rs | 26 +++++++-- codex-rs/linux-sandbox/src/bwrap_tests.rs | 66 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 92ef9dd54b77..11959d0c0224 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -899,11 +899,27 @@ fn expand_unreadable_globs_with_ripgrep( "unreadable glob `{pattern}` cannot be safely expanded; use a pattern with a non-root directory prefix" ))); }; - if search_root.as_path().is_dir() { - patterns_by_search_root - .entry(search_root) - .or_default() - .push(glob); + // Follow symlink roots, but never discard a deny because inspecting its + // search root failed. Only absent or non-directory roots have no matches. + match fs::metadata(search_root.as_path()) { + Ok(metadata) if metadata.is_dir() => { + patterns_by_search_root + .entry(search_root) + .or_default() + .push(glob); + } + Ok(_) => {} + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => {} + Err(err) => { + return Err(CodexErr::Fatal(format!( + "failed to inspect unreadable glob search root {}: {err}", + search_root.display() + ))); + } } } diff --git a/codex-rs/linux-sandbox/src/bwrap_tests.rs b/codex-rs/linux-sandbox/src/bwrap_tests.rs index 6de6c971755c..9f53e2c9e724 100644 --- a/codex-rs/linux-sandbox/src/bwrap_tests.rs +++ b/codex-rs/linux-sandbox/src/bwrap_tests.rs @@ -1460,6 +1460,72 @@ fn unreadable_globs_add_canonical_targets_for_symlink_matches() { assert_file_masked(&args.args, &real_secret); } +#[test] +fn unreadable_glob_root_metadata_errors_fail_before_scanning() { + let temp_dir = TempDir::new().expect("temp dir"); + let root = temp_dir.path().join("loop"); + std::os::unix::fs::symlink(&root, &root).expect("create symlink loop"); + let error = expand_unreadable_globs_with_ripgrep( + &[format!("{}/**/.env", root.display())], + temp_dir.path(), + || Some(temp_dir.path().join("scanner-must-not-run")), + /*max_depth*/ None, + |_| panic!("scanner must not run after a root metadata error"), + ) + .expect_err("metadata errors must reject deny-glob expansion") + .to_string(); + + assert!(error.contains(&root.display().to_string())); + assert!(error.contains(&io::Error::from_raw_os_error(libc::ELOOP).to_string())); +} + +#[test] +fn unreadable_globs_skip_absent_and_non_directory_roots() { + let temp_dir = TempDir::new().expect("temp dir"); + let missing = temp_dir.path().join("missing"); + let file = temp_dir.path().join("file"); + fs::write(&file, "ordinary file").expect("write file"); + + for root in [missing, file.join("child"), file] { + let paths = expand_unreadable_globs_with_ripgrep( + &[format!("{}/**/.env", root.display())], + temp_dir.path(), + || None, + /*max_depth*/ None, + |_| {}, + ) + .expect("roots without possible matches should be skipped"); + + assert_eq!(paths, Vec::::new()); + } +} + +#[test] +fn unreadable_globs_follow_symlink_roots_without_ripgrep() { + let temp_dir = TempDir::new().expect("temp dir"); + let root = temp_dir.path().join("data"); + fs::create_dir(&root).expect("create directory"); + let secret = root.join(".env"); + fs::write(&secret, "secret").expect("write secret"); + let link = temp_dir.path().join("link"); + std::os::unix::fs::symlink(&root, &link).expect("create directory symlink"); + + let paths = expand_unreadable_globs_with_ripgrep( + &[format!("{}/**/.env", link.display())], + temp_dir.path(), + || None, + /*max_depth*/ None, + |_| panic!("fallback scanner must not launch ripgrep"), + ) + .expect("expand deny glob"); + let expected = [secret, link.join(".env")] + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path_checked(path).expect("absolute path")) + .collect::>(); + + assert_eq!(paths, expected); +} + #[test] fn root_prefix_unreadable_globs_fail_closed_on_linux() { let policy = default_policy_with_unreadable_glob("/**/*.env".to_string()); From 77eb019ad039d21e5a346e77568739993a7f94cd Mon Sep 17 00:00:00 2001 From: nvogt-oai Date: Fri, 23 Oct 2026 22:45:59 +0000 Subject: [PATCH 019/287] Allow advisory rollout budgets with optional limit enforcement (#57187) ## What changed Add `features.rollout_budget.enforce_limit`, defaulting to `true`. Setting it to `false` keeps shared usage accounting and budget reminders active while allowing turns to continue after the budget is exhausted. ## Testing Extend coverage to check enforced and advisory budgets on the current and subsequent turns, including a zero-remaining-budget reminder in advisory mode. Verify that configuration defaults to enforcing the limit. GitOrigin-RevId: deeac8dae5714cc7c10d030cade6f3bfbfc8d3a7 --- codex-rs/core/config.schema.json | 6 +++- codex-rs/core/src/config/config_tests.rs | 1 + codex-rs/core/src/config/mod.rs | 2 ++ codex-rs/core/src/rollout_budget.rs | 5 +-- codex-rs/core/src/thread_manager_tests.rs | 1 + .../core/tests/suite/context_annotations.rs | 1 + .../tests/suite/guardian_context_budget.rs | 1 + codex-rs/core/tests/suite/rollout_budget.rs | 35 ++++++++++++++----- codex-rs/features/src/feature_configs.rs | 3 ++ 9 files changed, 43 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 264e19c8e075..bb6a528b0e38 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4495,6 +4495,10 @@ "enabled": { "type": "boolean" }, + "enforce_limit": { + "description": "Stop when the shared budget is exhausted. Defaults to true.", + "type": "boolean" + }, "limit_tokens": { "format": "int64", "minimum": 1.0, @@ -8514,4 +8518,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b79f113fad11..16c6fc8ef51f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -942,6 +942,7 @@ prefill_token_weight = 0.1 assert_eq!( config.rollout_budget, Some(RolloutBudgetConfig { + enforce_limit: true, limit_tokens: 100_000, reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 810df098ae0b..1f192747a4e9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1355,6 +1355,7 @@ impl Default for TokenBudgetConfig { #[derive(Debug, Clone, PartialEq, Serialize)] pub struct RolloutBudgetConfig { + pub enforce_limit: bool, pub limit_tokens: i64, pub reminder_at_remaining_tokens: Vec, pub sampling_token_weight: f64, @@ -3046,6 +3047,7 @@ fn resolve_rollout_budget_config( } } Ok(Some(RolloutBudgetConfig { + enforce_limit: config.enforce_limit.unwrap_or(true), limit_tokens, reminder_at_remaining_tokens, sampling_token_weight, diff --git a/codex-rs/core/src/rollout_budget.rs b/codex-rs/core/src/rollout_budget.rs index 1b010110a86d..332d15b17b67 100644 --- a/codex-rs/core/src/rollout_budget.rs +++ b/codex-rs/core/src/rollout_budget.rs @@ -44,7 +44,7 @@ impl RolloutBudget { }); } - /// Returns true once the configured budget is exhausted, including on later calls. + /// Records usage and returns whether an enforced budget is exhausted. pub(crate) fn record_usage(&self, usage: &TokenUsage) -> CodexResult { let Some(mut state) = self.lock() else { return Ok(false); @@ -63,7 +63,8 @@ impl RolloutBudget { + usage.non_cached_input() as f64 * state.config.prefill_token_weight }; state.weighted_tokens_used += units; - Ok(state.weighted_tokens_used >= state.config.limit_tokens as f64) + Ok(state.config.enforce_limit + && state.weighted_tokens_used >= state.config.limit_tokens as f64) } pub(crate) fn pending_reminder( diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index a2afab328529..b46fe16e2eab 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1288,6 +1288,7 @@ async fn spawn_internal_session_preserves_parent_lineage_without_forking_history let temp_dir = tempdir().expect("tempdir"); let mut config = test_config().await; config.rollout_budget = Some(RolloutBudgetConfig { + enforce_limit: true, limit_tokens: 100, reminder_at_remaining_tokens: vec![75, 50, 25], sampling_token_weight: 1.0, diff --git a/codex-rs/core/tests/suite/context_annotations.rs b/codex-rs/core/tests/suite/context_annotations.rs index d078c9bedf86..30b6f8194708 100644 --- a/codex-rs/core/tests/suite/context_annotations.rs +++ b/codex-rs/core/tests/suite/context_annotations.rs @@ -49,6 +49,7 @@ async fn first_request_item_types_roles_and_content_annotations() -> Result<()> ..TokenBudgetConfig::default() }); config.rollout_budget = Some(RolloutBudgetConfig { + enforce_limit: true, limit_tokens: 100, reminder_at_remaining_tokens: Vec::new(), sampling_token_weight: 1.0, diff --git a/codex-rs/core/tests/suite/guardian_context_budget.rs b/codex-rs/core/tests/suite/guardian_context_budget.rs index d966d37b92d8..ef336d3db32d 100644 --- a/codex-rs/core/tests/suite/guardian_context_budget.rs +++ b/codex-rs/core/tests/suite/guardian_context_budget.rs @@ -277,6 +277,7 @@ async fn review_respects_complete_context_budget( .expect("test permits time reminders"); config.current_time_reminder = Some(CurrentTimeReminderConfig::default()); config.rollout_budget = Some(RolloutBudgetConfig { + enforce_limit: true, limit_tokens: 1_000_000, reminder_at_remaining_tokens: Vec::new(), sampling_token_weight: 1.0, diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 8192a3f0c515..c0c244ab4538 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -30,6 +30,7 @@ const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; fn rollout_budget() -> RolloutBudgetConfig { RolloutBudgetConfig { + enforce_limit: true, limit_tokens: 100, reminder_at_remaining_tokens: vec![75, 50, 25], sampling_token_weight: 1.0, @@ -277,11 +278,15 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exhausted_budget_fails_current_and_later_turns() -> Result<()> { +#[test_case(true ; "enforced")] +#[test_case(false ; "advisory")] +async fn exhausted_budget_respects_enforcement_on_current_and_later_turns( + enforce_limit: bool, +) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - mount_sse_sequence( + let responses = mount_sse_sequence( &server, vec![ sse(vec![ @@ -296,8 +301,9 @@ async fn exhausted_budget_fails_current_and_later_turns() -> Result<()> { ) .await; let test = test_codex() - .with_config(|config| { + .with_config(move |config| { config.rollout_budget = Some(RolloutBudgetConfig { + enforce_limit, limit_tokens: 30, reminder_at_remaining_tokens: vec![20, 10], ..rollout_budget() @@ -314,20 +320,31 @@ async fn exhausted_budget_fails_current_and_later_turns() -> Result<()> { }])) .await?; - wait_for_event(&test.codex, |event| { - matches!( - event, - EventMsg::Error(error) - if error.codex_error_info == Some(CodexErrorInfo::SessionBudgetExceeded) - ) + let event = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::Error(_) | EventMsg::TurnComplete(_)) }) .await; + if !enforce_limit { + assert!(matches!(event, EventMsg::TurnComplete(_))); + continue; + } + assert!(matches!( + event, + EventMsg::Error(error) + if error.codex_error_info == Some(CodexErrorInfo::SessionBudgetExceeded) + )); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) .await; } + if !enforce_limit { + assert_eq!( + rollout_budget_texts(&responses.requests()[1]).last(), + Some(&rollout_budget_message(/*remaining_tokens*/ 0)) + ); + } Ok(()) } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 0ca7e1a1bbd7..1995ea99459b 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -421,6 +421,9 @@ impl FeatureConfig for TokenBudgetConfigToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RolloutBudgetConfigToml { + /// Stop when the shared budget is exhausted. Defaults to true. + #[serde(skip_serializing_if = "Option::is_none")] + pub enforce_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] From be7cd6fd9e44ab46205a30259fd613a9bd88bc38 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Fri, 23 Oct 2026 22:56:59 +0000 Subject: [PATCH 020/287] Add a credential file override for GCP provider authentication (#57188) ## What changed Support `model_providers.gcp.credential_file` as an absolute path to a Google Application Default Credentials (ADC) file. Pass it as `GOOGLE_APPLICATION_CREDENTIALS` in the provider auth command's environment, retaining the override for token refreshes without changing the parent process environment. Validate the path before merging GCP defaults and preserve the setting in thread configuration serialization. ## Testing Add coverage for invalid paths, configuration round trips, separate provider token caches, and credential overrides retained after a `401` refresh. Include a Windows regression test for credential paths containing command metacharacters. GitOrigin-RevId: d9a1d4b3775a9a4c4325dddaa153d92a2d1430a0 --- .../thread_processor_tests.rs | 1 + codex-rs/config/src/config_toml.rs | 22 ++++++ codex-rs/config/src/thread_config.rs | 1 + .../proto/codex.thread_config.v1.proto | 1 + .../proto/codex.thread_config.v1.rs | 2 + codex-rs/config/src/thread_config/remote.rs | 6 ++ codex-rs/core/config.schema.json | 4 ++ codex-rs/core/tests/responses_headers.rs | 3 + codex-rs/core/tests/suite/client.rs | 43 ++++++++++++ .../core/tests/suite/client_websockets.rs | 1 + codex-rs/core/tests/suite/gcp.rs | 63 +++++++++++++++++ .../suite/stream_error_allows_next_turn.rs | 1 + .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/login/src/auth/external_bearer.rs | 21 ++++-- codex-rs/login/src/auth/manager.rs | 13 +++- codex-rs/login/src/auth_env_telemetry.rs | 1 + codex-rs/model-provider-info/src/lib.rs | 29 +++++++- .../src/model_provider_info_tests.rs | 39 +++++++++++ codex-rs/model-provider/src/auth.rs | 14 +++- codex-rs/model-provider/src/gcp/gcp_tests.rs | 70 +++++++++++++++++++ codex-rs/model-provider/src/provider.rs | 1 + 21 files changed, 328 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index e4549bb1d3c8..9e510df839b0 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -658,6 +658,7 @@ mod thread_processor_behavior_tests { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; let config_manager = ConfigManager::new( diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index c3ac3e0b4b5c..98c02e6e5c15 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -986,6 +986,11 @@ pub fn validate_model_providers( "model_providers.{key}: provider name must not be empty" )); } + if key == GCP_PROVIDER_ID { + provider + .validate_gcp_credential_file() + .map_err(|message| format!("model_providers.{key}: {message}"))?; + } provider .validate() .map_err(|message| format!("model_providers.{key}: {message}"))?; @@ -1159,4 +1164,21 @@ command = "print-token" (Some("https://vertex.example.com/v1"), Some("print-token")) ); } + + #[test] + fn gcp_provider_rejects_relative_credential_file() { + for credential_file in ["", "adc.json", "../adc.json"] { + let error = toml::from_str::(&format!( + r#" +[model_providers.gcp] +credential_file = "{credential_file}" +"#, + )) + .expect_err("GCP overrides must validate credential paths before merging defaults"); + + assert!(error.to_string().contains( + "model_providers.gcp: provider credential_file must be an absolute path" + )); + } + } } diff --git a/codex-rs/config/src/thread_config.rs b/codex-rs/config/src/thread_config.rs index 16a2e09ad609..a40e59436a3d 100644 --- a/codex-rs/config/src/thread_config.rs +++ b/codex-rs/config/src/thread_config.rs @@ -319,6 +319,7 @@ mod tests { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } } diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto index efd43c4d208c..0208d5ece361 100644 --- a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto @@ -52,6 +52,7 @@ message ModelProvider { optional string model_catalog_url = 19; optional string project_id = 20; optional string location = 21; + optional string credential_file = 22; } message StringMap { diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs index 2e5a3788e39f..23fe7a280f21 100644 --- a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs @@ -83,6 +83,8 @@ pub struct ModelProvider { pub project_id: ::core::option::Option<::prost::alloc::string::String>, #[prost(string, optional, tag = "21")] pub location: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "22")] + pub credential_file: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct StringMap { diff --git a/codex-rs/config/src/thread_config/remote.rs b/codex-rs/config/src/thread_config/remote.rs index b9d8127da6e6..85be589dc490 100644 --- a/codex-rs/config/src/thread_config/remote.rs +++ b/codex-rs/config/src/thread_config/remote.rs @@ -198,6 +198,7 @@ fn model_provider_from_proto( capabilities: None, project_id: provider.project_id, location: provider.location, + credential_file: provider.credential_file.map(Into::into), include_internal_metadata: false, }; Ok((id, info)) @@ -232,6 +233,7 @@ fn model_provider_to_proto( supports_websockets, supports_standalone_web_search, include_internal_metadata: _, + credential_file, capabilities: _, } = provider; @@ -240,6 +242,7 @@ fn model_provider_to_proto( name, base_url, model_catalog_url: model_catalog_url.map(RedactedString::into_inner), + credential_file: credential_file.map(|path| path.to_string_lossy().into_owned()), env_key, env_key_instructions, experimental_bearer_token: experimental_bearer_token.map(RedactedString::into_inner), @@ -454,6 +457,7 @@ mod tests { let mut expected = expected_provider(); expected.project_id = Some("vertex-project".to_string()); expected.location = Some("us-central1".to_string()); + expected.credential_file = Some(workspace_dir().join("adc.json").into_path_buf()); expected.auth = None; expected.experimental_bearer_token = Some("synthetic-provider-token".into()); let proto = model_provider_to_proto("local", expected.clone()); @@ -492,6 +496,7 @@ mod tests { model_catalog_url: Some( "http://127.0.0.1:8061/api/codex/models".to_string(), ), + credential_file: None, env_key: None, env_key_instructions: None, experimental_bearer_token: None, @@ -597,6 +602,7 @@ mod tests { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bb6a528b0e38..bbfed66c0dec 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3034,6 +3034,10 @@ ], "description": "Optional API capability overrides for a custom Responses-compatible provider. Unspecified capabilities retain their existing provider defaults." }, + "credential_file": { + "description": "Absolute path to a Google ADC credential file. For GCP, overrides GOOGLE_APPLICATION_CREDENTIALS in the auth command's environment only.", + "type": "string" + }, "env_http_headers": { "additionalProperties": { "type": "string" diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 78215bf75db5..ef0102393a56 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -95,6 +95,7 @@ async fn responses_stream_includes_subagent_header_on_review() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -244,6 +245,7 @@ async fn responses_stream_includes_subagent_header_on_other() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -374,6 +376,7 @@ async fn responses_respects_model_info_overrides_from_config() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 624d3b9b1b11..2e192498cb27 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -910,6 +910,7 @@ impl ProviderAuthCommandFixture { codex_utils_cargo_bin::write_executable( &script_path, r#"#!/bin/sh +printf '%s\n' "$GOOGLE_APPLICATION_CREDENTIALS" >> credential-env.txt if [ -f fail-until-401 ]; then exit 1 fi @@ -929,6 +930,9 @@ mv tokens.next tokens.txt &script_path, r#"@echo off setlocal EnableExtensions DisableDelayedExpansion +setlocal EnableDelayedExpansion +echo(!GOOGLE_APPLICATION_CREDENTIALS!>> credential-env.txt +endlocal if exist fail-until-401 exit /b 1 set "first_line=" @@ -971,6 +975,41 @@ move /y tokens.next tokens.txt >nul } } +#[cfg(windows)] +#[test] +fn provider_auth_command_logs_credential_paths_with_cmd_metacharacters() -> anyhow::Result<()> { + let auth_fixture = ProviderAuthCommandFixture::new(&["first-token", "second-token"])?; + let credential_file = auth_fixture + .tempdir + .path() + .join("ADC (team)&^!credentials!%PATH%.json"); + + for (credential_path, token) in [ + (Some(credential_file.as_os_str()), "first-token"), + (None, "second-token"), + ] { + let mut command = codex_utils_process::background_command(&auth_fixture.command); + command + .args(&auth_fixture.args) + .current_dir(auth_fixture.tempdir.path()); + if let Some(credential_path) = credential_path { + command.env("GOOGLE_APPLICATION_CREDENTIALS", credential_path); + } else { + command.env_remove("GOOGLE_APPLICATION_CREDENTIALS"); + } + let output = command.output()?; + assert!(output.status.success(), "{output:?}"); + assert_eq!(String::from_utf8(output.stdout)?, format!("{token}\r\n")); + assert_eq!(output.stderr, Vec::::new()); + } + + assert_eq!( + std::fs::read_to_string(auth_fixture.tempdir.path().join("credential-env.txt"))?, + format!("{}\r\n\r\n", credential_file.display()), + ); + Ok(()) +} + fn non_zero_u64(value: u64) -> NonZeroU64 { NonZeroU64::new(value).expect("expected non-zero value") } @@ -1746,6 +1785,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -3254,6 +3294,7 @@ async fn azure_responses_request_does_not_store_and_preserves_prefixed_item_ids( capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -4104,6 +4145,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -4194,6 +4236,7 @@ async fn env_var_overrides_loaded_auth() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1486db4c2512..c48ae7640206 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2738,6 +2738,7 @@ fn websocket_provider_with_connect_timeout( capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } } diff --git a/codex-rs/core/tests/suite/gcp.rs b/codex-rs/core/tests/suite/gcp.rs index 098d59513f81..bc99bb761462 100644 --- a/codex-rs/core/tests/suite/gcp.rs +++ b/codex-rs/core/tests/suite/gcp.rs @@ -28,9 +28,72 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use test_case::test_case; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; use super::client::ProviderAuthCommandFixture; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn vertex_credential_file_is_retained_when_refreshing_after_401() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let auth_fixture = ProviderAuthCommandFixture::new(&["first-token", "second-token"])?; + let auth = auth_fixture.auth(); + let credential_file = auth.cwd.join("adc.json").into_path_buf(); + let credential_log = auth.cwd.join("credential-env.txt"); + let inherited_credential_file = std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(header("authorization", "Bearer first-token")) + .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) + .expect(1) + .mount(&server) + .await; + let response = responses::mount_sse_once_match( + &server, + header("authorization", "Bearer second-token"), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let mut provider = ModelProviderInfo::create_gcp_provider(); + provider.base_url = Some(format!("{}/v1", server.uri())); + provider.auth = Some(auth); + provider.credential_file = Some(credential_file.clone()); + let test = test_codex() + .with_config(move |config| { + config.model_provider_id = GCP_PROVIDER_ID.to_string(); + config.model_provider = provider; + }) + .build_with_auto_env(&server) + .await?; + + test.submit_turn("say hello").await?; + + assert_eq!( + response.single_request().header("authorization").as_deref(), + Some("Bearer second-token"), + ); + let observed = std::fs::read_to_string(credential_log)?; + let expected = credential_file.to_string_lossy(); + assert_eq!( + observed.lines().collect::>(), + vec![expected.as_ref(); 2] + ); + assert_eq!( + std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS"), + inherited_credential_file, + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn vertex_requests_use_command_auth_with_namespaces_and_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 196e202523f1..033c291f6d02 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -87,6 +87,7 @@ async fn continue_after_stream_error() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 5386eb236475..2bcef8928faf 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -73,6 +73,7 @@ async fn retries_on_early_close() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; diff --git a/codex-rs/login/src/auth/external_bearer.rs b/codex-rs/login/src/auth/external_bearer.rs index b8ad0d0672c6..ed58f4ccdbdf 100644 --- a/codex-rs/login/src/auth/external_bearer.rs +++ b/codex-rs/login/src/auth/external_bearer.rs @@ -3,6 +3,8 @@ use super::manager::ExternalAuth; use super::manager::ExternalAuthFuture; use super::manager::ExternalAuthRefreshContext; use codex_protocol::config_types::ModelProviderAuthInfo; +use std::collections::HashMap; +use std::ffi::OsString; use std::fmt; use std::io; use std::ops::Deref; @@ -20,9 +22,9 @@ pub(crate) struct BearerTokenRefresher { } impl BearerTokenRefresher { - pub(crate) fn new(config: ModelProviderAuthInfo) -> Self { + pub(crate) fn new(config: ModelProviderAuthInfo, env: HashMap) -> Self { Self { - state: Arc::new(ExternalBearerAuthState::new(config)), + state: Arc::new(ExternalBearerAuthState::new(config, env)), } } @@ -43,7 +45,8 @@ impl BearerTokenRefresher { } } - let access_token = run_provider_auth_command(&self.state.config).await?; + let access_token = + run_provider_auth_command(&self.state.config, &self.state.env).await?; *cached = Some(CachedExternalBearerToken { access_token: access_token.clone(), fetched_at: Instant::now(), @@ -54,7 +57,7 @@ impl BearerTokenRefresher { } async fn refresh(&self, _context: ExternalAuthRefreshContext) -> io::Result { - let access_token = run_provider_auth_command(&self.state.config).await?; + let access_token = run_provider_auth_command(&self.state.config, &self.state.env).await?; let mut cached = self.state.cached_token.lock().await; *cached = Some(CachedExternalBearerToken { access_token: access_token.clone(), @@ -83,13 +86,15 @@ impl fmt::Debug for BearerTokenRefresher { struct ExternalBearerAuthState { config: ModelProviderAuthInfo, + env: HashMap, cached_token: Mutex>, } impl ExternalBearerAuthState { - fn new(config: ModelProviderAuthInfo) -> Self { + fn new(config: ModelProviderAuthInfo, env: HashMap) -> Self { Self { config, + env, cached_token: Mutex::new(None), } } @@ -100,11 +105,15 @@ struct CachedExternalBearerToken { fetched_at: Instant, } -async fn run_provider_auth_command(config: &ModelProviderAuthInfo) -> io::Result { +async fn run_provider_auth_command( + config: &ModelProviderAuthInfo, + env: &HashMap, +) -> io::Result { let program = resolve_provider_auth_program(&config.command, &config.cwd)?; let mut command = Command::from(codex_utils_process::background_command(&program)); command .args(config.args.iter().map(Deref::deref)) + .envs(env) .current_dir(config.cwd.as_path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index ec292e7fcc71..7b8c7ffb5f99 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -13,7 +13,9 @@ use http::StatusCode; use serde::Deserialize; #[cfg(test)] use serial_test::serial; +use std::collections::HashMap; use std::env; +use std::ffi::OsString; use std::fmt::Debug; use std::future::Future; use std::path::Path; @@ -2408,6 +2410,15 @@ impl AuthManager { } pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc { + Self::external_bearer_only_with_env(config, HashMap::new()) + } + + /// Creates a provider-scoped token cache whose command environment overrides are + /// retained for both initial authentication and every subsequent refresh. + pub fn external_bearer_only_with_env( + config: ModelProviderAuthInfo, + env: HashMap, + ) -> Arc { let (auth_change_tx, _auth_change_rx) = watch::channel(0); Arc::new(Self { codex_home: PathBuf::from("non-existent"), @@ -2429,7 +2440,7 @@ impl AuthManager { refresh_lock: Arc::new(Semaphore::new(/*permits*/ 1)), agent_identity_lock: Semaphore::new(/*permits*/ 1), agent_identity_bootstrap_cooldown: Mutex::default(), - external_auth: RwLock::new(Some(Arc::new(BearerTokenRefresher::new(config)))), + external_auth: RwLock::new(Some(Arc::new(BearerTokenRefresher::new(config, env)))), workload_identity_selected: false, // External bearer auth refreshes by running the provider's command and never makes // auth-owned HTTP requests, so this route is intentionally inert. diff --git a/codex-rs/login/src/auth_env_telemetry.rs b/codex-rs/login/src/auth_env_telemetry.rs index 8f1c8ad8e1e7..fc40cf7fb6b0 100644 --- a/codex-rs/login/src/auth_env_telemetry.rs +++ b/codex-rs/login/src/auth_env_telemetry.rs @@ -82,6 +82,7 @@ mod tests { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 8fc8602dbc57..3e7860ec0a70 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -29,6 +29,7 @@ use std::fmt; use std::num::NonZeroU64; use std::path::Component; use std::path::Path; +use std::path::PathBuf; use std::sync::PoisonError; use std::sync::RwLock; use std::time::Duration; @@ -173,6 +174,9 @@ pub struct ModelProviderInfo { /// Google Cloud location for the built-in GCP provider. Takes precedence /// over `GOOGLE_CLOUD_LOCATION`; defaults to `global` when neither is set. pub location: Option, + /// Absolute path to a Google ADC credential file. For GCP, overrides + /// GOOGLE_APPLICATION_CREDENTIALS in the auth command's environment only. + pub credential_file: Option, /// Which wire protocol this provider expects. #[serde(default)] pub wire_api: WireApi, @@ -308,7 +312,20 @@ other non-default provider fields are not supported" Ok(()) } + /// Checks the credential-file path for a GCP provider or built-in GCP override. + pub fn validate_gcp_credential_file(&self) -> Result<(), String> { + if let Some(credential_file) = &self.credential_file + && !credential_file.is_absolute() + { + return Err("provider credential_file must be an absolute path".to_string()); + } + Ok(()) + } + pub fn validate(&self) -> std::result::Result<(), String> { + if self.is_gcp() { + self.validate_gcp_credential_file()?; + } if let Some(gateway) = &self.gateway_oauth { gateway.validate(self)?; } @@ -578,6 +595,7 @@ other non-default provider fields are not supported" capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: true, } } @@ -620,6 +638,7 @@ other non-default provider fields are not supported" capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } } @@ -658,6 +677,7 @@ other non-default provider fields are not supported" supports_standalone_web_search: false, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } } @@ -779,21 +799,27 @@ pub fn merge_configured_model_providers( } } } else if key == GCP_PROVIDER_ID { + provider + .validate_gcp_credential_file() + .and_then(|()| provider.validate()) + .map_err(|message| format!("model_providers.{GCP_PROVIDER_ID} {message}"))?; let base_url_override = provider.base_url.take(); let auth_override = provider.auth.take(); + let credential_file_override = provider.credential_file.take(); let http_headers_override = provider.http_headers.take(); let project_id_override = provider.project_id.take(); let location_override = provider.location.take(); if provider != ModelProviderInfo::default() { return Err(format!( "model_providers.{GCP_PROVIDER_ID} only supports changing \ -`base_url`, `auth`, `http_headers`, `project_id`, and `location`; other non-default provider fields are not supported" +`base_url`, `auth`, `http_headers`, `project_id`, `location`, and `credential_file`; other non-default provider fields are not supported" )); } if let Some(built_in_provider) = model_providers.get_mut(GCP_PROVIDER_ID) { built_in_provider.project_id = project_id_override; built_in_provider.location = location_override; + built_in_provider.credential_file = credential_file_override; if let Some(base_url_override) = base_url_override { built_in_provider.base_url = Some(base_url_override); } @@ -859,6 +885,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } } diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index 6156d0651320..ba3bd792901d 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -99,6 +99,7 @@ base_url = "http://localhost:11434/v1" capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -140,6 +141,7 @@ query_params = { api-version = "2025-04-01-preview" } capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -185,6 +187,7 @@ supports_standalone_web_search = true capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, }; @@ -377,6 +380,7 @@ fn test_create_amazon_bedrock_provider() { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } ); @@ -648,6 +652,7 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_transport_overri #[test] fn test_merge_configured_model_providers_applies_gcp_transport_overrides() { let auth = provider_auth_for_test(); + let credential_file = std::env::current_dir().unwrap().join("adc.json"); let configured_model_providers = std::collections::HashMap::from([( GCP_PROVIDER_ID.to_string(), ModelProviderInfo { @@ -655,6 +660,7 @@ fn test_merge_configured_model_providers_applies_gcp_transport_overrides() { project_id: Some("configured-project".to_string()), location: Some("us-east4".to_string()), auth: Some(auth.clone()), + credential_file: Some(credential_file.clone()), http_headers: Some(maplit::hashmap! { "x-example-header".to_string() => "value".into(), }), @@ -670,6 +676,7 @@ fn test_merge_configured_model_providers_applies_gcp_transport_overrides() { provider.project_id = Some("configured-project".to_string()); provider.location = Some("us-east4".to_string()); provider.auth = Some(auth); + provider.credential_file = Some(credential_file); provider.http_headers = Some(maplit::hashmap! { "x-example-header".to_string() => "value".into(), }); @@ -707,6 +714,38 @@ fn test_merge_configured_model_providers_applies_gcp_endpoint_override() { ); } +#[test] +fn test_merge_configured_gcp_rejects_relative_credential_file() { + for credential_file in ["", "adc.json", "../adc.json"] { + let provider = ModelProviderInfo { + credential_file: Some(credential_file.into()), + ..ModelProviderInfo::create_gcp_provider() + }; + assert_eq!( + provider.validate(), + Err("provider credential_file must be an absolute path".to_string()), + ); + + let overrides = HashMap::from([( + GCP_PROVIDER_ID.to_string(), + ModelProviderInfo { + credential_file: Some(credential_file.into()), + ..Default::default() + }, + )]); + + assert_eq!( + merge_configured_model_providers( + built_in_model_providers(/*openai_base_url*/ None), + overrides, + ), + Err( + "model_providers.gcp provider credential_file must be an absolute path".to_string() + ), + ); + } +} + #[test] fn test_merge_configured_model_providers_rejects_amazon_bedrock_non_default_fields() { let configured_model_providers = std::collections::HashMap::from([( diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index ba50f628bf92..e542b9d70ee6 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -240,7 +241,18 @@ pub(crate) fn auth_manager_for_provider( provider: &ModelProviderInfo, ) -> Option> { match provider.auth.clone() { - Some(config) => Some(AuthManager::external_bearer_only(config)), + Some(config) => { + let mut env = HashMap::new(); + if provider.is_gcp() + && let Some(credential_file) = &provider.credential_file + { + env.insert( + "GOOGLE_APPLICATION_CREDENTIALS".to_string(), + credential_file.as_os_str().to_os_string(), + ); + } + Some(AuthManager::external_bearer_only_with_env(config, env)) + } None => auth_manager, } } diff --git a/codex-rs/model-provider/src/gcp/gcp_tests.rs b/codex-rs/model-provider/src/gcp/gcp_tests.rs index fb3fbed09441..57e1e3cc1cff 100644 --- a/codex-rs/model-provider/src/gcp/gcp_tests.rs +++ b/codex-rs/model-provider/src/gcp/gcp_tests.rs @@ -164,6 +164,76 @@ async fn configured_base_url_bypasses_project_discovery() { ); } +#[tokio::test] +async fn credential_files_scope_token_caches_and_refreshes() { + let tempdir = tempfile::tempdir().unwrap(); + let first_file = tempdir.path().join("first credentials.json"); + let second_file = tempdir.path().join("second credentials.json"); + std::fs::write(&first_file, "first-token").unwrap(); + std::fs::write(&second_file, "second-token").unwrap(); + + #[cfg(unix)] + let (command, args) = ( + "sh".to_string(), + vec![ + "-c".into(), + "cat \"$GOOGLE_APPLICATION_CREDENTIALS\"".into(), + ], + ); + #[cfg(windows)] + let (command, args) = { + std::fs::write( + tempdir.path().join("read-token.cmd"), + "@type \"%GOOGLE_APPLICATION_CREDENTIALS%\"\r\n", + ) + .unwrap(); + ( + "cmd.exe".to_string(), + vec!["/d".into(), "/c".into(), r".\read-token.cmd".into()], + ) + }; + let mut first_info = ModelProviderInfo::create_gcp_provider(); + first_info.auth = Some(ModelProviderAuthInfo { + command, + args, + timeout_ms: std::num::NonZeroU64::new(5_000).unwrap(), + refresh_interval_ms: 300_000, + cwd: tempdir.path().try_into().unwrap(), + }); + first_info.credential_file = Some(first_file.clone()); + let mut second_info = first_info.clone(); + second_info.credential_file = Some(second_file); + let first = GcpModelProvider::new(first_info, /*auth_manager*/ None); + let second = GcpModelProvider::new(second_info, /*auth_manager*/ None); + + assert_eq!( + (first.auth().await, second.auth().await), + ( + Some(CodexAuth::from_api_key("first-token")), + Some(CodexAuth::from_api_key("second-token")), + ), + ); + std::fs::write(first_file, "refreshed-token").unwrap(); + assert_eq!( + first.auth().await, + Some(CodexAuth::from_api_key("first-token")) + ); + first + .auth_manager() + .unwrap() + .unauthorized_recovery() + .next() + .await + .unwrap(); + assert_eq!( + (first.auth().await, second.auth().await), + ( + Some(CodexAuth::from_api_key("refreshed-token")), + Some(CodexAuth::from_api_key("second-token")), + ), + ); +} + #[tokio::test] async fn bundled_vertex_models_filter_catalog_and_normalize_unsupported_tool_metadata() { let provider = GcpModelProvider::new( diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index d5cab7cc2059..17250c2540a0 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -748,6 +748,7 @@ mod tests { capabilities: None, project_id: None, location: None, + credential_file: None, include_internal_metadata: false, } } From 245691b2dc661ef5f6af8720d4556ae473d638ac Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 23 Oct 2026 23:05:39 +0000 Subject: [PATCH 021/287] Read unified exec output directly from stdout and stderr (#57191) ## What changed Consume local stdout and stderr receivers directly with `tokio::select!`, removing the intermediate broadcast channel and its lag-related chunk skipping. Drain both streams until they close. Pass `ExecOutputStream` through local and remote output collection to `OutputBuffers::push_chunk`, while retaining combined output buffers. ## Testing Update output-buffer tests for the stream argument and exercise stderr chunks in pushed-event and replay test fixtures. GitOrigin-RevId: be7804fd30cefeb812e372454665923ba051ddb4 --- codex-rs/core/src/unified_exec/process.rs | 44 ++++++++++--------- .../src/unified_exec/process_manager_tests.rs | 15 +++++-- .../suite/unified_exec_process_events.rs | 5 ++- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index ed223ac7b483..f9afaaa41d53 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -12,6 +12,7 @@ use tokio::task::JoinHandle; use tokio::time::Duration; use tokio_util::sync::CancellationToken; +use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEvent; use codex_exec_server::ProcessSignal as ExecServerProcessSignal; @@ -48,7 +49,7 @@ pub(crate) struct OutputBuffers OutputBuffers { - pub(super) fn push_chunk(&mut self, chunk: &[u8]) { + pub(super) fn push_chunk(&mut self, _stream: ExecOutputStream, chunk: &[u8]) { self.pending.push_chunk(chunk); self.transcript.push_chunk(chunk); } @@ -362,14 +363,14 @@ impl UnifiedExecProcess { stderr_rx, mut exit_rx, } = spawned; - let output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx); let mut managed = Self::new( ProcessHandle::Local(Box::new(process_handle)), Some(sandbox_type), OutputDrainPolicy::BoundedAfterExit, ); managed.output_task = Some(Self::spawn_local_output_task( - output_rx, + stdout_rx, + stderr_rx, managed.output_handles().clone(), managed.output_tx.clone(), )); @@ -558,7 +559,7 @@ impl UnifiedExecProcess { for chunk in chunks.into_iter().filter(|chunk| chunk.seq > last_seq) { let bytes = chunk.chunk.into_inner(); let mut guard = output_buffer.lock().await; - guard.push_chunk(&bytes); + guard.push_chunk(chunk.stream, &bytes); drop(guard); let _ = output_tx.send(bytes); output_notify.notify_waiters(); @@ -604,7 +605,7 @@ impl UnifiedExecProcess { last_seq = chunk.seq; let bytes = chunk.chunk.into_inner(); let mut guard = output_buffer.lock().await; - guard.push_chunk(&bytes); + guard.push_chunk(chunk.stream, &bytes); drop(guard); let _ = output_tx.send(bytes); output_notify.notify_waiters(); @@ -654,7 +655,8 @@ impl UnifiedExecProcess { } fn spawn_local_output_task( - mut receiver: tokio::sync::broadcast::Receiver>, + mut stdout: tokio::sync::mpsc::Receiver>, + mut stderr: tokio::sync::mpsc::Receiver>, output_handles: OutputHandles, output_tx: broadcast::Sender>, ) -> JoinHandle<()> { @@ -670,22 +672,22 @@ impl UnifiedExecProcess { output_closed: Arc::clone(&output_closed), output_closed_notify: Arc::clone(&output_closed_notify), }; - loop { - match receiver.recv().await { - Ok(chunk) => { - let mut guard = output_buffer.lock().await; - guard.push_chunk(&chunk); - drop(guard); - let _ = output_tx.send(chunk); - output_notify.notify_waiters(); - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - output_closed.store(true, Ordering::Release); - output_closed_notify.notify_waiters(); - break; - } + let mut stdout_open = true; + let mut stderr_open = true; + while stdout_open || stderr_open { + let (stream, chunk) = tokio::select! { + chunk = stdout.recv(), if stdout_open => (ExecOutputStream::Stdout, chunk), + chunk = stderr.recv(), if stderr_open => (ExecOutputStream::Stderr, chunk), }; + if let Some(chunk) = chunk { + output_buffer.lock().await.push_chunk(stream, &chunk); + let _ = output_tx.send(chunk); + output_notify.notify_waiters(); + } else if stream == ExecOutputStream::Stdout { + stdout_open = false; + } else { + stderr_open = false; + } } }) } diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index cc560d3cba42..bf91afca3216 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -3,6 +3,7 @@ use crate::shell::Shell; use crate::shell::ShellInvocation; use crate::shell::ShellType; use crate::unified_exec::clamp_yield_time; +use codex_exec_server::ExecOutputStream; use codex_network_proxy::ManagedNetworkSandboxContext; use core_test_support::assert_regex_match; use pretty_assertions::assert_eq; @@ -366,7 +367,10 @@ async fn output_collection_stays_bounded_across_repeated_drains() { ); let produce = async { for chunk in chunks { - output_buffer.lock().await.push_chunk(chunk); + output_buffer + .lock() + .await + .push_chunk(ExecOutputStream::Stdout, chunk); output_notify.notify_one(); tokio::time::timeout(Duration::from_secs(1), async { loop { @@ -398,8 +402,8 @@ async fn output_collection_stays_bounded_across_repeated_drains() { #[tokio::test] async fn output_collection_preserves_omissions_from_drained_buffer() { let mut buffered_output = OutputBuffers::<10>::default(); - buffered_output.push_chunk(&[b'a'; 10]); - buffered_output.push_chunk(b"overflow"); + buffered_output.push_chunk(ExecOutputStream::Stdout, &[b'a'; 10]); + buffered_output.push_chunk(ExecOutputStream::Stdout, b"overflow"); let mut expected = HeadTailBuffer::<10>::default(); expected.push_chunk(&[b'a'; 10]); expected.push_chunk(b"overflow"); @@ -496,7 +500,10 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { }; let output_buffer = Arc::new(tokio::sync::Mutex::new(OutputBuffers::default())); - output_buffer.lock().await.push_chunk(b"PARTIAL_TRANSCRIPT"); + output_buffer + .lock() + .await + .push_chunk(ExecOutputStream::Stdout, b"PARTIAL_TRANSCRIPT"); emit_failed_initial_exec_end_if_unstored( /*process_stored*/ false, diff --git a/codex-rs/core/tests/suite/unified_exec_process_events.rs b/codex-rs/core/tests/suite/unified_exec_process_events.rs index 1454676e0f26..d59e777d0b42 100644 --- a/codex-rs/core/tests/suite/unified_exec_process_events.rs +++ b/codex-rs/core/tests/suite/unified_exec_process_events.rs @@ -415,6 +415,7 @@ async fn serve_exec_with_pushed_events( _ => b"x", } }; + let replay_stream = |seq| if seq == 1 { "stderr" } else { "stdout" }; if matches!(scenario, PushedExecScenario::ReplayGap) { // The process replay log retains 256 events. This burst is much larger // than both that log and the JSON-RPC event queue, so the reader must @@ -430,7 +431,7 @@ async fn serve_exec_with_pushed_events( "params": { "processId": &process_id, "seq": seq, - "stream": "stdout", + "stream": replay_stream(seq), "chunk": BASE64_STANDARD.encode(replay_output(seq)), } }), @@ -792,7 +793,7 @@ async fn serve_exec_with_pushed_events( .map(|seq| { json!({ "seq": seq, - "stream": "stdout", + "stream": replay_stream(seq), "chunk": BASE64_STANDARD.encode(replay_output(seq)), }) }) From 6654f7a7ab28b5163fc2c5f459bd90e4fa35f631 Mon Sep 17 00:00:00 2001 From: Anthony Tafoya Date: Fri, 23 Oct 2026 23:13:22 +0000 Subject: [PATCH 022/287] Gate capability discovery V2 on executor version (#57192) ## Why Executors older than `0.164.0` can advertise V2 discovery support without implementing the final contract. Enabling V2 also suppressed V1 discovery for incompatible executors, hiding selected capability roots and skills. ## What changed Require both the V2 capability flag and an executor version of at least `0.164.0` before issuing `capabilities/discoverV2`. Share this compatibility check between session discovery and the exec-server client. Fall back to V1 when incompatible executors provide no V2 snapshots, clearing any previously stored V2 inventory. ## Testing Add coverage for version boundaries, prereleases, missing and malformed versions, and transport recovery for compatible executors. Verify that an older executor retains selected plugins and skill instructions through V1 with V2 enabled. GitOrigin-RevId: 93adf52fc91f02b58fd14f8ff3b093712b3346b8 --- codex-rs/core/src/session/mcp.rs | 23 ++++-- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/tests.rs | 1 + codex-rs/core/tests/common/exec_server.rs | 26 ++++++- .../core/tests/suite/executor_discovery_v2.rs | 74 +++++++++++-------- codex-rs/core/tests/suite/scenarios.rs | 3 +- .../suite/scenarios_frozen_cloud_skills.rs | 3 +- .../exec-server/src/client_refresh_tests.rs | 8 +- codex-rs/exec-server/src/environment.rs | 1 + .../src/environment/discovery_v2.rs | 17 +++-- .../src/environment/discovery_v2_tests.rs | 32 ++++++-- codex-rs/exec-server/src/lib.rs | 1 + 12 files changed, 139 insertions(+), 51 deletions(-) diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 95301d6bc357..b312cc7345a1 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -10,6 +10,7 @@ use codex_exec_server::ExecutorCapabilityDiscoveryV2Cache; use codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot; use codex_exec_server::MAX_SELECTED_CAPABILITY_ROOTS; use codex_exec_server::ResolvedSelectedCapabilityRoot; +use codex_exec_server::supports_capability_discovery_v2; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ElicitationReviewRequest; use codex_mcp::ElicitationReviewer; @@ -133,6 +134,7 @@ impl Session { config, &selected_capability_roots, &environments, + executor_capability_discovery_v2.as_deref(), ) .await; if inventory_changed || discovery_recovered { @@ -247,6 +249,7 @@ impl Session { &desired.config, &selected_capability_roots, &desired.environments, + executor_capability_discovery_v2.as_deref(), ) .await; let mcp_projection = self @@ -329,6 +332,7 @@ impl Session { &desired.config, &selected_capability_roots, &desired.environments, + executor_capability_discovery_v2.as_deref(), ) .await; let mcp_projection = self @@ -481,11 +485,9 @@ impl Session { config: &Config, resolved_roots: &[ResolvedSelectedCapabilityRoot], environments: &TurnEnvironmentSnapshot, + v2_snapshots: Option<&[ExecutorCapabilityDiscoveryV2Snapshot]>, ) -> (Option>, bool) { - if config - .features - .enabled(Feature::ExecutorCapabilityDiscoveryV2) - { + if v2_snapshots.is_some() { return (None, false); } let restricted_file_system = environments.primary().map_or_else( @@ -577,6 +579,7 @@ impl Session { .count(); cache.ensure_capacity(remote_environment_count); let mut snapshots = Vec::new(); + let mut skipped_incompatible_executor = false; for selected in environments.turn_environments() { if !selected.environment.is_remote() { continue; @@ -592,7 +595,8 @@ impl Session { continue; } }; - if !info.capabilities.capability_discovery_v2 { + if !supports_capability_discovery_v2(&info) { + skipped_incompatible_executor = true; continue; } let request = DiscoverV2CapabilitiesRequest { @@ -607,6 +611,14 @@ impl Session { result, }); } + if snapshots.is_empty() && skipped_incompatible_executor { + let changed = self + .services + .thread_extension_data + .remove::>() + .is_some(); + return (None, changed); + } let previous = self .services .thread_extension_data @@ -870,6 +882,7 @@ impl Session { &desired.config, &selected_capability_roots, &desired.environments, + executor_capability_discovery_v2.as_deref(), ) .await; let mcp_projection = self diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8e5f7cf0fab1..b6dccabe0257 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -4059,6 +4059,7 @@ impl Session { &turn_context.config, &selected_capability_roots, &environments, + executor_capability_discovery_v2.as_deref(), ) .await }; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 182e03e58853..241ead83e87b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -10146,6 +10146,7 @@ async fn capability_discovery_uses_environment_permission_profile() { &turn_context.config, /*resolved_roots*/ &[], &turn_context.initial_environments, + /*v2_snapshots*/ None, ) .await; let discovery = discovery.expect("restricted environment should trigger capability discovery"); diff --git a/codex-rs/core/tests/common/exec_server.rs b/codex-rs/core/tests/common/exec_server.rs index 3d9b392a7c03..70f321cec0ce 100644 --- a/codex-rs/core/tests/common/exec_server.rs +++ b/codex-rs/core/tests/common/exec_server.rs @@ -30,10 +30,32 @@ impl ExecServerProcess { pub async fn start_with_env(env: &[(&str, &str)]) -> Result { let codex_home = TempDir::new()?; - let mut child = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?) + let mut command = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + command.envs(env.iter().copied()); + Self::start_command(command, codex_home).await + } + + pub async fn start_with_version(version: &str) -> Result { + let codex_home = TempDir::new()?; + let package = codex_home.path().join("package"); + let bin = package.join("bin"); + std::fs::create_dir_all(&bin)?; + let executable = bin.join(format!("codex{}", std::env::consts::EXE_SUFFIX)); + codex_utils_cargo_bin::copy_executable( + &codex_utils_cargo_bin::cargo_bin("codex")?, + &executable, + )?; + std::fs::write( + package.join("codex-package.json"), + serde_json::to_vec(&serde_json::json!({"version": version}))?, + )?; + Self::start_command(Command::new(executable), codex_home).await + } + + async fn start_command(mut command: Command, codex_home: TempDir) -> Result { + let mut child = command .args(["exec-server", "--listen", "ws://127.0.0.1:0"]) .env("CODEX_HOME", codex_home.path()) - .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/codex-rs/core/tests/suite/executor_discovery_v2.rs b/codex-rs/core/tests/suite/executor_discovery_v2.rs index 20d88fcaa361..8ad711e5a07b 100644 --- a/codex-rs/core/tests/suite/executor_discovery_v2.rs +++ b/codex-rs/core/tests/suite/executor_discovery_v2.rs @@ -209,6 +209,14 @@ enum DiscoveryFailure { impl Executor { async fn start(supports_v2: bool, failure: DiscoveryFailure) -> Result { + Self::start_with_version(supports_v2, failure, "0.164.0").await + } + + async fn start_with_version( + supports_v2: bool, + failure: DiscoveryFailure, + version: &'static str, + ) -> Result { let listener = TcpListener::bind("127.0.0.1:0").await?; let url = format!("ws://{}", listener.local_addr()?); let requests = Arc::new(Mutex::new(Vec::new())); @@ -267,15 +275,23 @@ impl Executor { let result = match request["method"].as_str() { Some("initialize") => Some(json!({"sessionId":"acquisition-only", "environmentInfo": { "shell":{"name":"bash","path":"/bin/bash"}, + "executorVersion":version, "capabilities":{"sandboxedFileStreaming":true,"capabilityDiscoveryV2":supports_v2} }})), Some("environment/info") => Some(json!({ "shell":{"name":"bash","path":"/bin/bash"}, + "executorVersion":version, "capabilities":{"sandboxedFileStreaming":true,"capabilityDiscoveryV2":supports_v2} })), Some("capabilityRoots/discoverV1") => Some(json!({ "roots": request["params"]["roots"].as_array().context("V1 roots")? - .iter().map(|root| json!({"id":root["id"],"path":root["path"]})).collect::>() + .iter().map(|root| json!({ + "id":root["id"],"path":root["path"], + "skills":[{"instructions":{ + "path":format!("{}/inert-sentinel/SKILL.md", root["path"].as_str().expect("root URI").trim_end_matches('/')), + "contents":"---\nname: inert-sentinel\ndescription: Executor skill used by the acquisition test\n---\n# Sentinel instructions from the captured executor" + }}] + })).collect::>() })), Some("capabilities/discoverV2") => Some(inventory.lock().await.clone().unwrap_or_else(|| json!({ "plugins":[{"id":"inert-sentinel@market","displayName":"inert-sentinel","remotePluginId":null, @@ -322,12 +338,12 @@ impl Executor { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn v2_disabled_skips_inventory_discovery() -> Result<()> { - check_acquisition(/*enabled*/ false, /*supported*/ true).await + check_acquisition(/*enabled*/ false, "0.164.0").await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn v2_shares_inventory_with_mcp_and_skills_until_next_turn() -> Result<()> { - check_acquisition(/*enabled*/ true, /*supported*/ true).await + check_acquisition(/*enabled*/ true, "0.164.0").await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -537,9 +553,15 @@ async fn v2_discovery_failures_retry_or_remain_cached_by_error_kind() -> Result< Ok(()) } -async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn v2_old_executor_retains_v1_selected_roots() -> Result<()> { + check_acquisition(/*enabled*/ true, "0.163.0").await +} + +async fn check_acquisition(enabled: bool, version: &'static str) -> Result<()> { skip_if_no_network!(Ok(())); - let executor = Executor::start(supported, DiscoveryFailure::None).await?; + let executor = + Executor::start_with_version(/*supports_v2*/ true, DiscoveryFailure::None, version).await?; let model = responses::start_mock_server().await; let observed = Arc::new(Mutex::new(Vec::new())); let selected_plugins = Arc::new(Mutex::new(None)); @@ -601,7 +623,7 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> { .filter(|request| request["method"] == "capabilities/discoverV2") .count() }; - let acquires_v2 = enabled && supported; + let acquires_v2 = enabled && version == "0.164.0"; if acquires_v2 { observed.lock().await.clear(); mcp_snapshots_per_step.lock().await.clear(); @@ -678,13 +700,11 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> { for body in bodies { responses.push(responses::mount_sse_once(&model, body).await); } - let prompt = if acquires_v2 { - "Use $inert-sentinel to say hello." - } else { - "Say hello." - }; - test.submit_turn_with_environments(prompt, Some(vec![selection.clone()])) - .await?; + test.submit_turn_with_environments( + "Use $inert-sentinel to say hello.", + Some(vec![selection.clone()]), + ) + .await?; if acquires_v2 { let counts = mcp_snapshots_per_step.lock().await; let first = counts @@ -708,7 +728,7 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> { plugin_id: "inert-sentinel@market".to_string(), }; // Both paths publish accepted Cloud packages and available installations only. - let mut expected_plugins = vec![ + let expected_plugins = vec![ (Some(executor_root), local_identity), ( None, @@ -717,9 +737,6 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> { }, ), ]; - if enabled && !supported { - expected_plugins.retain(|(root, _)| root.is_none()); - } { let selected_plugins = selected_plugins.lock().await; let snapshot = selected_plugins.as_ref().context("step plugin snapshot")?; @@ -739,20 +756,17 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> { .collect::>(); assert_eq!(model_requests.len(), if acquires_v2 { 2 } else { 1 }); for request in model_requests { - if acquires_v2 { - assert!( - request - .body_json() - .to_string() - .contains("Sentinel instructions from the captured executor") - ); - } + assert!( + request + .body_json() + .to_string() + .contains("Sentinel instructions from the captured executor") + ); let developer_text = request.message_input_texts("developer").join("\n"); - assert_eq!( + assert!( developer_text .contains("- inert-sentinel: Executor skill used by the acquisition test"), - acquires_v2, - "only a successful V2 inventory renders the skill catalog", + "both discovery modes must render the skill catalog", ); } let turn_calls = calls().await; @@ -796,8 +810,8 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> { requests .iter() .any(|request| request["method"] == "capabilityRoots/discoverV1"), - !enabled, - "the V2 flag selects one protocol, including when no executor supplies a V2 snapshot", + !acquires_v2, + "incompatible executors retain V1 discovery even with the V2 flag enabled", ); if acquires_v2 { let reads = requests diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index bd71eddee7e4..afe1c89c1669 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -1409,7 +1409,8 @@ async fn skill_read_recovers_omitted_installation_and_reads_selected_environment } } - let executor = core_test_support::exec_server::ExecServerProcess::start().await?; + let executor = + core_test_support::exec_server::ExecServerProcess::start_with_version("0.164.0").await?; // Keep byte-budget decisions stable across host temporary-directory lengths. let temp_root = fs::canonicalize(std::env::temp_dir())?; let workspace = tempfile::Builder::new() diff --git a/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs b/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs index 1f9230ef8490..f863af7aac14 100644 --- a/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs +++ b/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs @@ -86,7 +86,8 @@ async fn frozen_cloud_skills_with_named_executor_request_history() -> Result<()> }) .build(&server) .await?; - let executor = core_test_support::exec_server::ExecServerProcess::start().await?; + let executor = + core_test_support::exec_server::ExecServerProcess::start_with_version("0.164.0").await?; test.thread_manager .environment_manager() .upsert_environment( diff --git a/codex-rs/exec-server/src/client_refresh_tests.rs b/codex-rs/exec-server/src/client_refresh_tests.rs index b5d5c7c60797..255f99b599ac 100644 --- a/codex-rs/exec-server/src/client_refresh_tests.rs +++ b/codex-rs/exec-server/src/client_refresh_tests.rs @@ -135,7 +135,13 @@ impl NoiseStreamHandler for NotifyingProcessor { ConnectionProcessor::decode(payload) } - fn encode(message: Self::Outgoing) -> Result { + fn encode(mut message: Self::Outgoing) -> Result { + // Advertise the stable contract implemented by this test executor. + if let codex_exec_server_protocol::JSONRPCMessage::Response(response) = &mut message + && let Some(info) = response.result.get_mut("environmentInfo") + { + info["executorVersion"] = serde_json::json!("0.164.0"); + } ConnectionProcessor::encode(message) } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 0b046061dfc8..23fbea1ec7c1 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,6 +1,7 @@ mod connect_options; mod discovery_recovery; mod discovery_v2; +pub use discovery_v2::supports_capability_discovery_v2; use std::collections::HashMap; use std::collections::HashSet; diff --git a/codex-rs/exec-server/src/environment/discovery_v2.rs b/codex-rs/exec-server/src/environment/discovery_v2.rs index 71daf0e3c471..800189f19e08 100644 --- a/codex-rs/exec-server/src/environment/discovery_v2.rs +++ b/codex-rs/exec-server/src/environment/discovery_v2.rs @@ -3,7 +3,16 @@ use super::Environment; use crate::DiscoverV2CapabilitiesRequest; use crate::DiscoverV2CapabilitiesResponse; +use crate::EnvironmentInfo; use crate::ExecServerError; +use codex_build_info::BuildInfo; + +/// The final DiscoveryV2 contract starts at stable 0.164.0. +pub fn supports_capability_discovery_v2(info: &EnvironmentInfo) -> bool { + let build = BuildInfo::from_version(&info.executor_version); + let minimum = BuildInfo::from_version("0.164.0"); + info.capabilities.capability_discovery_v2 && build.version() >= minimum.version() +} impl Environment { /// Identifies the initialized executor session; transport recovery preserves this ID. @@ -28,12 +37,8 @@ impl Environment { let connection_state = client.subscribe_connection_state(); let client = client.get().await?; let discover = || async { - if !client - .environment_info() - .await? - .capabilities - .capability_discovery_v2 - { + let info = client.environment_info().await?; + if !supports_capability_discovery_v2(&info) { return Err(ExecServerError::Protocol( "capabilities/discoverV2 is unavailable in this environment session" .to_string(), diff --git a/codex-rs/exec-server/src/environment/discovery_v2_tests.rs b/codex-rs/exec-server/src/environment/discovery_v2_tests.rs index db280f09d9fb..fa09a3081afd 100644 --- a/codex-rs/exec-server/src/environment/discovery_v2_tests.rs +++ b/codex-rs/exec-server/src/environment/discovery_v2_tests.rs @@ -1,4 +1,4 @@ -//! Real transport recovery preserves a discovery request's cwd and retries it once. +//! Version compatibility gates discovery RPCs; supported clients retain transport recovery. use super::DiscoverV2CapabilitiesRequest; use super::DiscoverV2CapabilitiesResponse; @@ -21,8 +21,18 @@ use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; use tokio_util::task::AbortOnDropHandle; +#[test_case::test_case("0.164.0", true; "minimum_stable")] +#[test_case::test_case("0.168.0+build", true; "newer_stable")] +#[test_case::test_case("0.163.0", false; "older")] +#[test_case::test_case("0.164.0-alpha.1", false; "prerelease_before_cutoff")] +#[test_case::test_case("0.165.0-alpha.1", true; "next_minor_prerelease")] +#[test_case::test_case("", false; "missing")] +#[test_case::test_case("invalid", false; "malformed")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn discovery_retries_disconnect_within_the_same_request() -> anyhow::Result<()> { +async fn discovery_checks_version_and_retries_disconnect( + version: &'static str, + supported: bool, +) -> anyhow::Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let url = format!("ws://{}", listener.local_addr()?); let attempts = Arc::new(Mutex::new(Vec::new())); @@ -38,10 +48,13 @@ async fn discovery_retries_disconnect_within_the_same_request() -> anyhow::Resul if request.get("id").is_none() { continue; } - let info = json!({ + let mut info = json!({ "shell":{"name":"bash","path":"/bin/bash"}, "capabilities":{"capabilityDiscoveryV2":true} }); + if !version.is_empty() { + info["executorVersion"] = json!(version); + } let result = match request["method"].as_str() { Some("initialize") => { // V2 support is advertised by the executor, not negotiated by the client. @@ -87,8 +100,17 @@ async fn discovery_retries_disconnect_within_the_same_request() -> anyhow::Resul Duration::from_secs(12), environment.discover_capabilities_v2(params.clone()), ) - .await??; - assert_eq!(response, DiscoverV2CapabilitiesResponse::default()); + .await?; + if !supported { + assert!( + matches!(&response, Err(crate::ExecServerError::Protocol(message)) + if message == "capabilities/discoverV2 is unavailable in this environment session"), + "unexpected discovery response: {response:?}" + ); + assert_eq!(*attempts.lock().await, Vec::::new()); + return Ok(()); + } + assert_eq!(response?, DiscoverV2CapabilitiesResponse::default()); assert_eq!( *attempts.lock().await, vec![ diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index cd928c0fed0e..6d77d325a21e 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -125,6 +125,7 @@ pub use environment::LOCAL_ENVIRONMENT_ID; pub use environment::MAX_SELECTED_CAPABILITY_ROOTS; pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment::RemoteEnvironmentOptions; +pub use environment::supports_capability_discovery_v2; pub use environment_bootstrap::PreparedEnvironmentManager; pub use environment_provider::DefaultEnvironmentProvider; pub use environment_provider::EnvironmentProvider; From cfc11bfab31c6885ec8213ece435fc3347eda7a4 Mon Sep 17 00:00:00 2001 From: cassirer-openai Date: Fri, 23 Oct 2026 23:15:07 +0000 Subject: [PATCH 023/287] Preserve failed `write_stdin` calls in rollout trace replay (#57193) ## Why Invalid model arguments to `write_stdin` represent a tool failure, but parsing them could fail rollout trace replay. ## What changed Skip terminal operation creation when dispatched `write_stdin` arguments cannot be parsed or lack a usable `session_id`. Preserve the generic tool call and its error, including the original arguments, so replay can continue. ## Testing Add a regression test covering malformed JSON and missing or null `session_id` values. Verify that replay retains the failed call's arguments and error without creating terminal operations or sessions. GitOrigin-RevId: 967e975a88072c169eb3e391d5c62e807d608fbf --- .../src/reducer/tool/terminal.rs | 12 ++-- .../src/reducer/tool/terminal_tests.rs | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/codex-rs/rollout-trace/src/reducer/tool/terminal.rs b/codex-rs/rollout-trace/src/reducer/tool/terminal.rs index 13f56d746155..d2b21cc127e2 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/terminal.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/terminal.rs @@ -455,10 +455,14 @@ fn parse_dispatch_terminal_request(value: JsonValue) -> Result(&arguments) else { + return Ok(None); + }; + let Some(process_id) = process_id_from_json(&args.session_id) else { + return Ok(None); + }; Ok(Some(ParsedTerminalRequest { process_id: Some(process_id), diff --git a/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs b/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs index cd7ac5914699..8d06b074db17 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs @@ -1,7 +1,12 @@ +use std::sync::Arc; + use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; +use crate::ToolDispatchInvocation; +use crate::ToolDispatchPayload; +use crate::ToolDispatchRequester; use crate::model::ExecutionStatus; use crate::model::ExecutionWindow; use crate::model::TerminalModelObservation; @@ -15,6 +20,7 @@ use crate::model::ToolCallKind; use crate::model::ToolCallSummary; use crate::payload::RawPayloadKind; use crate::raw_event::RawTraceEventPayload; +use crate::reducer::test_support::ROOT_THREAD_ID; use crate::reducer::test_support::create_started_writer; use crate::reducer::test_support::generic_summary; use crate::reducer::test_support::message; @@ -24,6 +30,7 @@ use crate::reducer::test_support::start_turn_for_thread; use crate::reducer::test_support::trace_context; use crate::reducer::test_support::trace_context_for_thread; use crate::replay_bundle; +use crate::tool_dispatch::ToolDispatchTraceContext; use crate::writer::TraceWriter; #[test] @@ -491,6 +498,58 @@ fn dispatch_write_stdin_payload_reduces_to_terminal_operation() -> anyhow::Resul Ok(()) } +#[test] +fn invalid_write_stdin_arguments_remain_failed_tool_calls() -> anyhow::Result<()> { + for arguments in [r#"{"session_id": "#, "{}", r#"{"session_id": null}"#] { + let temp = TempDir::new()?; + let writer = Arc::new(create_started_writer(&temp)?); + start_turn(&writer, "turn-1")?; + let dispatch = ToolDispatchTraceContext::start( + Arc::clone(&writer), + ToolDispatchInvocation { + thread_id: ROOT_THREAD_ID.to_string(), + codex_turn_id: "turn-1".to_string(), + tool_call_id: "call-stdin".to_string(), + tool_name: "write_stdin".to_string(), + tool_namespace: None, + requester: ToolDispatchRequester::Model { + model_visible_call_id: "call-stdin".to_string(), + }, + payload: ToolDispatchPayload::Function { + arguments: arguments.to_string(), + }, + }, + ); + dispatch.record_failed("invalid arguments"); + + let rollout = replay_bundle(temp.path())?; + let call = &rollout.tool_calls["call-stdin"]; + assert_eq!(call.execution.status, ExecutionStatus::Failed); + assert_eq!(call.terminal_operation_id, None); + assert!(rollout.terminal_operations.is_empty()); + assert!(rollout.terminal_sessions.is_empty()); + // The failed call still retains its original arguments and error. + for (id, expected) in [ + ( + call.raw_invocation_payload_id.as_ref().unwrap(), + json!({"tool_name": "write_stdin", "tool_namespace": null, + "payload": {"type": "function", "arguments": arguments}}), + ), + ( + call.raw_result_payload_id.as_ref().unwrap(), + json!({"type": "error", "error": "invalid arguments"}), + ), + ] { + let payload = std::fs::read(temp.path().join(&rollout.raw_payloads[id].path))?; + assert_eq!( + serde_json::from_slice::(&payload)?, + expected + ); + } + } + Ok(()) +} + #[test] fn code_mode_write_stdin_result_projects_structured_exec_fields() -> anyhow::Result<()> { let temp = TempDir::new()?; From b6a4378862f89683a8a19cbfd253f6f4f972c40a Mon Sep 17 00:00:00 2001 From: cassirer-openai Date: Fri, 23 Oct 2026 23:32:49 +0000 Subject: [PATCH 024/287] Classify rollout tool calls by their runtime handler (#57194) ## Why Tool interception and MCP aliases can route a requested callable to a different handler. Rollout traces need to reflect the handler that actually executes, including when an `exec_command` invocation is intercepted as `apply_patch`. ## What changed Carry an explicit `ToolCallKind` in runtime-start events and use it to update the tool call's execution classification. This replaces MCP-specific payload inspection while preserving the original callable summary and invocation for intercepted patch and aliased MCP calls. ## Testing Add a regression test verifying that an intercepted `exec_command` is recorded as `ApplyPatch`, retains its original summary and invocation, completes successfully, and creates no terminal operations or sessions. GitOrigin-RevId: 0e72f3ef55cda2796e779efd16241e4a0dbf0c5b --- codex-rs/rollout-trace/src/protocol_event.rs | 17 ++++ codex-rs/rollout-trace/src/raw_event.rs | 2 + codex-rs/rollout-trace/src/reducer/mod.rs | 2 + codex-rs/rollout-trace/src/reducer/tool.rs | 12 ++- .../src/reducer/tool/agents_tests.rs | 3 + .../rollout-trace/src/reducer/tool/mcp.rs | 44 ---------- .../src/reducer/tool/mcp_tests.rs | 1 + .../src/reducer/tool/terminal_tests.rs | 4 +- codex-rs/rollout-trace/src/thread.rs | 2 + codex-rs/rollout-trace/src/thread_tests.rs | 87 +++++++++++++++++++ 10 files changed, 122 insertions(+), 52 deletions(-) delete mode 100644 codex-rs/rollout-trace/src/reducer/tool/mcp.rs diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index d68df83e6e28..5253a07cb45c 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -31,6 +31,7 @@ use crate::AgentThreadId; use crate::CodexTurnId; use crate::ExecutionStatus; use crate::RawTraceEventPayload; +use crate::ToolCallKind; pub(crate) struct CodexTurnTraceEvent { pub context_turn_id: CodexTurnId, @@ -93,6 +94,7 @@ pub(crate) fn codex_turn_trace_event( pub(crate) enum ToolRuntimeTraceEvent<'a> { Started { tool_call_id: &'a str, + kind: ToolCallKind, payload: ToolRuntimePayload<'a>, }, Ended { @@ -280,6 +282,12 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option { Some(ToolRuntimeTraceEvent::Started { tool_call_id: &event.call_id, + kind: match event.source { + ExecCommandSource::UnifiedExecInteraction => ToolCallKind::WriteStdin, + ExecCommandSource::Agent + | ExecCommandSource::UserShell + | ExecCommandSource::UnifiedExecStartup => ToolCallKind::ExecCommand, + }, payload: ToolRuntimePayload::ExecCommandBegin(event), }) } @@ -292,6 +300,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Some(ToolRuntimeTraceEvent::Started { tool_call_id: &event.call_id, + kind: ToolCallKind::ApplyPatch, payload: ToolRuntimePayload::PatchApplyBegin(event), }), EventMsg::PatchApplyEnd(event) => Some(ToolRuntimeTraceEvent::Ended { @@ -301,6 +310,10 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Some(ToolRuntimeTraceEvent::Started { tool_call_id: &event.call_id, + kind: ToolCallKind::Mcp { + server: event.invocation.server.clone(), + tool: event.invocation.tool.clone(), + }, payload: ToolRuntimePayload::McpToolCallBegin(event), }), EventMsg::McpToolCallEnd(event) => Some(ToolRuntimeTraceEvent::Ended { @@ -314,6 +327,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Some(ToolRuntimeTraceEvent::Started { tool_call_id: &event.call_id, + kind: ToolCallKind::SpawnAgent, payload: ToolRuntimePayload::CollabAgentSpawnBegin(event), }), EventMsg::CollabAgentSpawnEnd(event) => Some(ToolRuntimeTraceEvent::Ended { @@ -329,6 +343,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Some(ToolRuntimeTraceEvent::Started { tool_call_id: &event.call_id, + kind: ToolCallKind::SendMessage, payload: ToolRuntimePayload::CollabAgentInteractionBegin(event), }), EventMsg::CollabAgentInteractionEnd(event) => Some(ToolRuntimeTraceEvent::Ended { @@ -338,6 +353,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Some(ToolRuntimeTraceEvent::Started { tool_call_id: &event.call_id, + kind: ToolCallKind::WaitAgent, payload: ToolRuntimePayload::CollabWaitingBegin(event), }), EventMsg::CollabWaitingEnd(event) => Some(ToolRuntimeTraceEvent::Ended { @@ -347,6 +363,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Some(ToolRuntimeTraceEvent::Started { tool_call_id: &event.call_id, + kind: ToolCallKind::CloseAgent, payload: ToolRuntimePayload::CollabCloseBegin(event), }), EventMsg::CollabCloseEnd(event) => Some(ToolRuntimeTraceEvent::Ended { diff --git a/codex-rs/rollout-trace/src/raw_event.rs b/codex-rs/rollout-trace/src/raw_event.rs index 88880d673a62..711ea2a00d6f 100644 --- a/codex-rs/rollout-trace/src/raw_event.rs +++ b/codex-rs/rollout-trace/src/raw_event.rs @@ -156,6 +156,8 @@ pub enum RawTraceEventPayload { }, ToolCallRuntimeStarted { tool_call_id: ToolCallId, + /// Executed handler, which may differ from the requested callable. + kind: ToolCallKind, /// Runtime/protocol observation for how Codex began executing the tool. runtime_payload: RawPayloadRef, }, diff --git a/codex-rs/rollout-trace/src/reducer/mod.rs b/codex-rs/rollout-trace/src/reducer/mod.rs index 5487654fa20b..b3c3a8eb752a 100644 --- a/codex-rs/rollout-trace/src/reducer/mod.rs +++ b/codex-rs/rollout-trace/src/reducer/mod.rs @@ -340,12 +340,14 @@ impl TraceReducer { } RawTraceEventPayload::ToolCallRuntimeStarted { tool_call_id, + kind, runtime_payload, } => { self.start_tool_runtime_observation( event.seq, event.wall_time_unix_ms, tool_call_id, + kind, runtime_payload, )?; } diff --git a/codex-rs/rollout-trace/src/reducer/tool.rs b/codex-rs/rollout-trace/src/reducer/tool.rs index 45913b0a2b41..485a08af8edb 100644 --- a/codex-rs/rollout-trace/src/reducer/tool.rs +++ b/codex-rs/rollout-trace/src/reducer/tool.rs @@ -19,7 +19,6 @@ use crate::raw_event::RawEventSeq; use crate::raw_event::RawToolCallRequester; mod agents; -mod mcp; mod terminal; #[cfg(test)] @@ -242,26 +241,25 @@ impl TraceReducer { seq: RawEventSeq, wall_time_unix_ms: i64, tool_call_id: ToolCallId, + kind: ToolCallKind, runtime_payload: RawPayloadRef, ) -> Result<()> { - let (thread_id, _requester, kind, existing_terminal_operation_id) = { + let (thread_id, existing_terminal_operation_id) = { let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else { bail!("tool runtime start referenced unknown call {tool_call_id}"); }; + // Interception and MCP aliases can route a callable to a different handler. + // Keep its original summary/invocation, but classify execution by that handler. + tool_call.kind = kind.clone(); push_unique( &mut tool_call.raw_runtime_payload_ids, &runtime_payload.raw_payload_id, ); ( tool_call.thread_id.clone(), - tool_call.requester.clone(), - tool_call.kind.clone(), tool_call.terminal_operation_id.clone(), ) }; - if self.classify_mcp_runtime_start(&tool_call_id, &runtime_payload)? { - return Ok(()); - } if existing_terminal_operation_id.is_some() && matches!(kind, ToolCallKind::ExecCommand | ToolCallKind::WriteStdin) { diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs b/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs index f5a4648f8021..f7fec30c44d7 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents_tests.rs @@ -329,6 +329,7 @@ fn send_message_runtime_payload_targets_delivered_child_message() -> anyhow::Res writer.append_with_context( trace_context_for_agent("turn-1"), RawTraceEventPayload::ToolCallRuntimeStarted { + kind: ToolCallKind::SendMessage, tool_call_id: "call-send".to_string(), runtime_payload: begin_payload, }, @@ -610,6 +611,7 @@ fn close_agent_runtime_payload_targets_thread() -> anyhow::Result<()> { writer.append_with_context( trace_context_for_agent("turn-1"), RawTraceEventPayload::ToolCallRuntimeStarted { + kind: ToolCallKind::CloseAgent, tool_call_id: "call-close".to_string(), runtime_payload: begin_payload.clone(), }, @@ -918,6 +920,7 @@ fn append_spawn_agent_tool_lifecycle( writer.append_with_context( trace_context_for_agent(turn_id), RawTraceEventPayload::ToolCallRuntimeStarted { + kind: ToolCallKind::SpawnAgent, tool_call_id: "call-spawn".to_string(), runtime_payload: begin.clone(), }, diff --git a/codex-rs/rollout-trace/src/reducer/tool/mcp.rs b/codex-rs/rollout-trace/src/reducer/tool/mcp.rs deleted file mode 100644 index 9305b6731402..000000000000 --- a/codex-rs/rollout-trace/src/reducer/tool/mcp.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Resolve MCP backend identity without replacing the caller-facing invocation. - -use anyhow::Context; -use anyhow::Result; -use codex_protocol::protocol::McpInvocation; -use serde::Deserialize; - -use crate::model::ToolCallKind; -use crate::payload::RawPayloadRef; -use crate::reducer::TraceReducer; - -impl TraceReducer { - /// Returns whether the runtime start identifies an MCP backend call. - pub(super) fn classify_mcp_runtime_start( - &mut self, - tool_call_id: &str, - runtime_payload: &RawPayloadRef, - ) -> Result { - // MCP begin events precede correlation assignment, and callable names - // can match built-ins. Only the runtime payload identifies the backend. - let payload = self.read_payload_json(runtime_payload)?; - let Some(invocation) = payload.get("invocation") else { - return Ok(false); - }; - let invocation = McpInvocation::deserialize(invocation).with_context(|| { - format!( - "parse MCP runtime invocation in {}", - runtime_payload.raw_payload_id - ) - })?; - // Callable namespaces are aliases, not backend names: `mcp__web` can - // route to `web`, while other MCP namespaces have no `mcp__` prefix. - // Keep the original callable in the summary and raw invocation payload. - self.rollout - .tool_calls - .get_mut(tool_call_id) - .with_context(|| format!("MCP runtime start referenced unknown call {tool_call_id}"))? - .kind = ToolCallKind::Mcp { - server: invocation.server, - tool: invocation.tool, - }; - Ok(true) - } -} diff --git a/codex-rs/rollout-trace/src/reducer/tool/mcp_tests.rs b/codex-rs/rollout-trace/src/reducer/tool/mcp_tests.rs index 5c222b8a3952..15776537660b 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/mcp_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/mcp_tests.rs @@ -94,6 +94,7 @@ fn mcp_calls_keep_callable_identity_and_classify_the_backend() -> anyhow::Result }), )?; writer.append(RawTraceEventPayload::ToolCallRuntimeStarted { + kind: expected.kind.clone(), tool_call_id: "call-mcp".to_string(), runtime_payload: begin.clone(), })?; diff --git a/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs b/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs index 8d06b074db17..9763aa0ea13e 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs @@ -78,6 +78,7 @@ fn exec_tool_reduces_to_terminal_operation_and_session() -> anyhow::Result<()> { let runtime_start = writer.append_with_context( trace_context("turn-1"), RawTraceEventPayload::ToolCallRuntimeStarted { + kind: ToolCallKind::ExecCommand, tool_call_id: "tool-1".to_string(), runtime_payload: runtime_start_payload, }, @@ -267,7 +268,7 @@ fn reused_process_ids_keep_sessions_and_stdin_joins_distinct() -> anyhow::Result model_visible_call_id: None, code_mode_runtime_tool_id: None, requester: crate::raw_event::RawToolCallRequester::Model, - kind, + kind: kind.clone(), summary: generic_summary("terminal"), invocation_payload: None, }, @@ -285,6 +286,7 @@ fn reused_process_ids_keep_sessions_and_stdin_joins_distinct() -> anyhow::Result let runtime_start = writer.append_with_context( trace_context_for_thread(thread, turn), RawTraceEventPayload::ToolCallRuntimeStarted { + kind, tool_call_id, runtime_payload: request, }, diff --git a/codex-rs/rollout-trace/src/thread.rs b/codex-rs/rollout-trace/src/thread.rs index 5aee8637c285..969bdc776c53 100644 --- a/codex-rs/rollout-trace/src/thread.rs +++ b/codex-rs/rollout-trace/src/thread.rs @@ -497,12 +497,14 @@ impl EnabledThreadTraceContext { match trace_event { crate::protocol_event::ToolRuntimeTraceEvent::Started { tool_call_id, + kind, payload, } => { let runtime_payload = self .write_json_payload_best_effort(RawPayloadKind::ToolRuntimeEvent, &payload)?; Some(RawTraceEventPayload::ToolCallRuntimeStarted { tool_call_id: tool_call_id.to_string(), + kind, runtime_payload, }) } diff --git a/codex-rs/rollout-trace/src/thread_tests.rs b/codex-rs/rollout-trace/src/thread_tests.rs index e0a5547c334a..2ffb232db4fa 100644 --- a/codex-rs/rollout-trace/src/thread_tests.rs +++ b/codex-rs/rollout-trace/src/thread_tests.rs @@ -1,15 +1,23 @@ use std::cell::Cell; +use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use pretty_assertions::assert_eq; use tempfile::TempDir; use super::*; @@ -18,6 +26,10 @@ use crate::CompactionCheckpointTracePayload; use crate::ExecutionStatus; use crate::RawTraceEventPayload; use crate::RolloutStatus; +use crate::ToolCallKind; +use crate::ToolDispatchPayload; +use crate::ToolDispatchRequester; +use crate::ToolDispatchResult; use crate::replay_bundle; #[test] @@ -225,6 +237,81 @@ fn protocol_wrapper_records_selected_events_as_raw_payloads() -> anyhow::Result< Ok(()) } +#[test] +fn intercepted_exec_records_patch_without_a_terminal() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let thread_id = ThreadId::new(); + let trace = + ThreadTraceContext::start_root_in_root_for_test(temp.path(), minimal_metadata(thread_id))?; + trace.record_codex_turn_started("turn-1"); + let dispatch = trace.start_tool_dispatch_trace(|| Some(ToolDispatchInvocation { + thread_id: thread_id.to_string(), + codex_turn_id: "turn-1".to_string(), + tool_call_id: "patch-call".to_string(), + tool_name: "exec_command".to_string(), + tool_namespace: None, + requester: ToolDispatchRequester::Model { + model_visible_call_id: "patch-call".to_string(), + }, + payload: ToolDispatchPayload::Function { + arguments: serde_json::json!({"cmd": "apply_patch <<'EOF'\n*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\nEOF"}).to_string(), + }, + })); + let bundle = single_bundle_dir(temp.path())?; + let before = replay_bundle(&bundle)?.tool_calls["patch-call"].clone(); + let changes = HashMap::from([( + PathBuf::from("hello.txt"), + FileChange::Add { + content: "hello\n".to_string(), + }, + )]); + trace.record_tool_call_event( + "turn-1", + &EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "patch-call".to_string(), + turn_id: "turn-1".to_string(), + auto_approved: true, + changes: changes.clone(), + }), + ); + trace.record_tool_call_event( + "turn-1", + &EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: "patch-call".to_string(), + turn_id: "turn-1".to_string(), + stdout: "Success".to_string(), + stderr: String::new(), + success: true, + changes, + status: PatchApplyStatus::Completed, + }), + ); + dispatch.record_completed( + ExecutionStatus::Completed, + ToolDispatchResult::DirectResponse { + response_item: ResponseInputItem::FunctionCallOutput { + call_id: "patch-call".to_string(), + output: FunctionCallOutputPayload::from_text("Success".to_string()), + }, + }, + ); + + let rollout = replay_bundle(&bundle)?; + let call = &rollout.tool_calls["patch-call"]; + assert_eq!(call.kind, ToolCallKind::ApplyPatch); + assert_eq!(call.execution.status, ExecutionStatus::Completed); + assert_eq!(call.summary, before.summary); + assert_eq!( + call.raw_invocation_payload_id, + before.raw_invocation_payload_id + ); + assert_eq!(call.raw_runtime_payload_ids.len(), 2); + assert!(call.raw_result_payload_id.is_some()); + assert!(rollout.terminal_operations.is_empty()); + assert!(rollout.terminal_sessions.is_empty()); + Ok(()) +} + fn minimal_metadata(thread_id: ThreadId) -> ThreadStartedTraceMetadata { ThreadStartedTraceMetadata { thread_id: thread_id.to_string(), From 7d1492ffabc9a8d734d729aa5d184823f143de8b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 23 Oct 2026 23:39:13 +0000 Subject: [PATCH 025/287] Isolate MCP inputs and timeouts for callers sharing a connection (#57196) ## Why Concurrent requests on a shared MCP connection need to route prompts to their own callers and pause only their own timeouts while awaiting input. ## What changed - Add `call_tool_with_elicitation` and `read_resource_with_elicitation` with per-request callbacks and timeout pause state, requiring MCP protocol `2026-07-28`. - Share continuation handling between tool calls and resource reads, and assign globally unique input IDs across requests and connections. - Add `enable_request_cancellation` to extend cancellation on dropped response futures to catalog, resource, and custom requests. ## Testing Add or extend tests for concurrent form and URL input routing, unique prompt IDs, independent native verification cancellation and timeouts, resource continuations, and remote cancellation when resource or custom request futures are dropped. GitOrigin-RevId: ad5a3cd0e289726828a694d00c20870e2d1108c6 --- .../src/elicitation_client_service.rs | 13 + codex-rs/rmcp-client/src/lib.rs | 4 +- .../rmcp-client/src/request_cancellation.rs | 55 +++ .../src/{tool_input.rs => request_input.rs} | 128 +++++-- codex-rs/rmcp-client/src/rmcp_client.rs | 342 +++++++++++++++--- codex-rs/rmcp-client/src/tool_input_tests.rs | 8 +- codex-rs/rmcp-client/src/tool_request.rs | 49 --- .../user_verification_cancellation_tests.rs | 77 ++++ codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs | 102 ++++-- .../native_verification_tests.rs | 79 +++- 10 files changed, 668 insertions(+), 189 deletions(-) create mode 100644 codex-rs/rmcp-client/src/request_cancellation.rs rename codex-rs/rmcp-client/src/{tool_input.rs => request_input.rs} (65%) delete mode 100644 codex-rs/rmcp-client/src/tool_request.rs diff --git a/codex-rs/rmcp-client/src/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index ef2fbacd92d8..8a54bc93679b 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -94,6 +94,19 @@ impl Drop for PendingVerification { } impl ElicitationClientService { + /// Retains protocol validation and cancellation dispatch while isolating the caller's inputs. + pub(crate) fn for_request( + &self, + send_elicitation: Arc, + pause_state: ElicitationPauseState, + ) -> Self { + Self { + send_elicitation, + pause_state, + ..self.clone() + } + } + pub(crate) fn new( client_info: ClientConfig, send_elicitation: SendElicitation, diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index a46b300771a2..0cdfed8ca7f6 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -27,13 +27,13 @@ mod ordered_byte_transport; mod perform_oauth_login; mod program_resolver; mod protocol_mode; +mod request_cancellation; +mod request_input; mod rmcp_client; mod service_error; mod startup_error; mod stdio_server_launcher; -mod tool_input; mod tool_list_changes; -mod tool_request; mod trace_context; mod user_verification; mod utils; diff --git a/codex-rs/rmcp-client/src/request_cancellation.rs b/codex-rs/rmcp-client/src/request_cancellation.rs new file mode 100644 index 000000000000..8f0d9cadc5b4 --- /dev/null +++ b/codex-rs/rmcp-client/src/request_cancellation.rs @@ -0,0 +1,55 @@ +//! Cancel remote MCP work when its local response future is dropped. + +use rmcp::Peer; +use rmcp::RoleClient; +use rmcp::model::CancelledNotificationParam; +use rmcp::model::ClientRequest; +use rmcp::model::RequestId; +use rmcp::model::ServerResult; +use rmcp::service::PeerRequestOptions; +use rmcp::service::ServiceError; + +/// Whether dropping a caller's future should cancel its request on the peer. +#[derive(Clone, Copy)] +pub(crate) enum RequestCancellation { + Disabled, + OnDrop, +} + +impl RequestCancellation { + /// Send a request and tie its remote lifetime to the caller when requested. + pub(crate) async fn send( + self, + peer: &Peer, + request: ClientRequest, + options: PeerRequestOptions, + ) -> Result { + let handle = peer.send_request_with_option(request, options).await?; + let mut cancellation = CancelOnDrop(match self { + Self::Disabled => None, + Self::OnDrop => Some((peer.clone(), handle.id.clone())), + }); + let result = handle.await_response().await; + cancellation.0 = None; + result + } +} + +struct CancelOnDrop(Option<(Peer, RequestId)>); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some((peer, id)) = self.0.take() + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + runtime.spawn(async move { + let _ = peer + .notify_cancelled(CancelledNotificationParam::new( + Some(id), + Some("MCP response future dropped".into()), + )) + .await; + }); + } + } +} diff --git a/codex-rs/rmcp-client/src/tool_input.rs b/codex-rs/rmcp-client/src/request_input.rs similarity index 65% rename from codex-rs/rmcp-client/src/tool_input.rs rename to codex-rs/rmcp-client/src/request_input.rs index e06a8aaa26ee..9b39fe96673a 100644 --- a/codex-rs/rmcp-client/src/tool_input.rs +++ b/codex-rs/rmcp-client/src/request_input.rs @@ -1,17 +1,24 @@ -//! Drives modern tool continuations, including the native verification extension. -//! Inputs use the existing client service; proofs never bypass its validation. +//! Drives modern request continuations, including the native verification extension. +//! Inputs use the caller's client service; proofs never bypass its validation. //! Session recovery cannot replay a submitted proof; connection closure drops pending inputs. use std::collections::BTreeMap; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use std::time::Duration; use rmcp::RoleClient; +use rmcp::model::CallToolRequest; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; +use rmcp::model::ClientRequest; use rmcp::model::DEFAULT_MRTR_MAX_ROUNDS; use rmcp::model::GetExtensions; use rmcp::model::GetMeta; use rmcp::model::InputRequest; +use rmcp::model::ReadResourceRequest; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; use rmcp::model::ServerRequest; use rmcp::model::ServerResult; @@ -26,28 +33,71 @@ use serde_json::Value; use crate::elicitation_client_service::ElicitationClientService; use crate::http_client_adapter::StreamableHttpClientAdapterError; +use crate::request_cancellation::RequestCancellation; + +static NEXT_INPUT_ID: AtomicU64 = AtomicU64::new(0); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct ToolInput { +struct RequiredInput { input_requests: Option>, request_state: Option, } pub(crate) async fn call_tool( service: &RunningService, - mut params: CallToolRequestParams, + input_service: &ElicitationClientService, + params: CallToolRequestParams, ) -> Result { + match request( + service, + input_service, + ClientRequest::CallToolRequest(CallToolRequest::new(params)), + RequestCancellation::OnDrop, + ) + .await? + { + ServerResult::CallToolResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } +} + +pub(crate) async fn read_resource( + service: &RunningService, + input_service: &ElicitationClientService, + params: ReadResourceRequestParams, + cancellation: RequestCancellation, +) -> Result { + match request( + service, + input_service, + ClientRequest::ReadResourceRequest(ReadResourceRequest::new(params)), + cancellation, + ) + .await? + { + ServerResult::ReadResourceResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } +} + +async fn request( + service: &RunningService, + input_service: &ElicitationClientService, + mut request: ClientRequest, + cancellation: RequestCancellation, +) -> Result { let mut state_only_rounds = 0; let mut submitted_verification_proof = false; for round in 0..DEFAULT_MRTR_MAX_ROUNDS { - let response = crate::tool_request::call_tool( - service.peer(), - params.clone(), - PeerRequestOptions::no_options(), - ) - .await; - let (id, result) = response.map_err(|error| { + let response = cancellation + .send( + service.peer(), + request.clone(), + PeerRequestOptions::no_options(), + ) + .await; + let result = response.map_err(|error| { if submitted_verification_proof && let ServiceError::TransportSend(transport) = &error && !matches!( @@ -60,15 +110,18 @@ pub(crate) async fn call_tool( // Session recovery must not replay the original operation after a // continuation (which may already have consumed a signed proof). // Auth challenges already return without replaying the operation. - invalid("MCP tool continuation failed; the tool was not restarted") + invalid("MCP continuation failed; the operation was not restarted") } else { error } })?; let input = match result { - ServerResult::CallToolResult(result) => return Ok(result), - ServerResult::InputRequiredResult(result) => serde_json::to_value(result) - .map_err(|_| invalid("invalid MCP tool input request"))?, + result @ (ServerResult::CallToolResult(_) | ServerResult::ReadResourceResult(_)) => { + return Ok(result); + } + ServerResult::InputRequiredResult(result) => { + serde_json::to_value(result).map_err(|_| invalid("invalid MCP input request"))? + } // RMCP's InputRequest union intentionally excludes custom methods. ServerResult::CustomResult(result) if result.0.get("resultType").and_then(Value::as_str) == Some("input_required") => @@ -80,8 +133,8 @@ pub(crate) async fn call_tool( if round + 1 == DEFAULT_MRTR_MAX_ROUNDS { break; } - let input: ToolInput = - serde_json::from_value(input).map_err(|_| invalid("invalid MCP tool input request"))?; + let input: RequiredInput = + serde_json::from_value(input).map_err(|_| invalid("invalid MCP input request"))?; let requests = input.input_requests.unwrap_or_default(); if requests.is_empty() && input.request_state.is_none() { return Err(ServiceError::UnexpectedResponse); @@ -99,35 +152,30 @@ pub(crate) async fn call_tool( } else { state_only_rounds = 0; } - let responses = futures::future::try_join_all(requests.into_iter().enumerate().map( - |(index, (key, mut request))| { - // Native prompts need distinct UI/cancellation ownership when - // concurrent tool calls use the same server-assigned input key. + let responses = + futures::future::try_join_all(requests.into_iter().map(|(key, mut request)| { + // Prompts outlive individual transport requests, including across + // session recovery, so their IDs must not reuse a peer's counter. let native_verification = matches!(&request, ServerRequest::CustomRequest(request) if request.params.as_ref().and_then(|params| params.get("mode")) .and_then(Value::as_str) == Some(crate::user_verification::MODE)); - let request_id = if native_verification { - RequestId::String(format!("tool-input/{id}/{index}").into()) - } else { - RequestId::String(key.clone().into()) - }; + let id = NEXT_INPUT_ID.fetch_add(1, Ordering::Relaxed); + let request_id = RequestId::String(format!("mcp-input/{id}").into()); let mut context = RequestContext::new(request_id, service.peer().clone()); context.meta = std::mem::take(request.get_meta_mut()); context.extensions = std::mem::take(request.extensions_mut()); async move { - let result = service - .service() + let result = input_service .handle_request(request, context) .await .map_err(ServiceError::McpError)?; let result = serde_json::to_value(result) - .map_err(|_| invalid("invalid MCP tool input response"))?; + .map_err(|_| invalid("invalid MCP input response"))?; let contains_proof = native_verification && result.get("action").and_then(Value::as_str) == Some("accept"); Ok::<_, ServiceError>((key, result, contains_proof)) } - }, - )); + })); let responses = tokio::select! { biased; _ = async { @@ -140,7 +188,7 @@ pub(crate) async fn call_tool( } => return Err(ServiceError::TransportClosed), responses = responses => responses?, }; - params.input_responses = (!responses.is_empty()).then(|| { + let input_responses = (!responses.is_empty()).then(|| { responses .into_iter() .map(|(key, result, contains_proof)| { @@ -149,7 +197,17 @@ pub(crate) async fn call_tool( }) .collect() }); - params.request_state = input.request_state; + match &mut request { + ClientRequest::CallToolRequest(request) => { + request.params.input_responses = input_responses; + request.params.request_state = input.request_state; + } + ClientRequest::ReadResourceRequest(request) => { + request.params.input_responses = input_responses; + request.params.request_state = input.request_state; + } + _ => return Err(ServiceError::UnexpectedResponse), + } } Err(ServiceError::InputRequiredRoundsExceeded { max_rounds: DEFAULT_MRTR_MAX_ROUNDS, @@ -168,7 +226,7 @@ fn parse_input(value: Value) -> Result { }; } let request: ServerRequest = - serde_json::from_value(value).map_err(|_| invalid("invalid MCP tool input request"))?; + serde_json::from_value(value).map_err(|_| invalid("invalid MCP input request"))?; if let ServerRequest::CustomRequest(request) = &request && request.method == "openai/elicitation/create" { @@ -190,7 +248,7 @@ fn parse_input(value: Value) -> Result { } return Ok(ServerRequest::CustomRequest(request.clone())); } - Err(invalid("unsupported MCP tool input request")) + Err(invalid("unsupported MCP input request")) } fn invalid(message: &'static str) -> ServiceError { diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index d5cdf26aa1f5..ed3a144d5df4 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::OnceLock; use std::sync::PoisonError; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -39,8 +40,11 @@ use rmcp::model::ElicitResult; use rmcp::model::ElicitationAction; use rmcp::model::Extensions; use rmcp::model::InitializeRequestParams; +use rmcp::model::ListResourceTemplatesRequest; use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesRequest; use rmcp::model::ListResourcesResult; +use rmcp::model::ListToolsRequest; use rmcp::model::ListToolsResult; use rmcp::model::MetaObject; use rmcp::model::PaginatedRequestParams; @@ -97,6 +101,7 @@ use crate::oauth_http_client::OAuthHttpClientAdapter; use crate::oauth_refresh_mode::McpOAuthRefreshMode; use crate::ordered_byte_transport::OrderedByteTransport; use crate::protocol_mode::McpProtocolMode; +use crate::request_cancellation::RequestCancellation; use crate::startup_error::is_authentication_required_error; use crate::stdio_server_launcher::StdioServerCommand; use crate::stdio_server_launcher::StdioServerLauncher; @@ -414,9 +419,23 @@ pub struct RmcpClient { session_recovery_lock: Semaphore, elicitation_pause_state: ElicitationPauseState, tool_list_revision: Arc, + cancel_requests_on_drop: AtomicBool, } impl RmcpClient { + /// Extend tool-call cancellation to catalog, resource, and custom requests on a shared connection. + pub fn enable_request_cancellation(&self) { + self.cancel_requests_on_drop.store(true, Ordering::Relaxed); + } + + fn request_cancellation(&self) -> RequestCancellation { + if self.cancel_requests_on_drop.load(Ordering::Relaxed) { + RequestCancellation::OnDrop + } else { + RequestCancellation::Disabled + } + } + /// Tool catalog invalidation sequence, ordered before subsequent responses. pub fn tool_list_revision(&self) -> crate::ToolListRevision { crate::ToolListRevision(Arc::clone(&self.tool_list_revision)) @@ -460,6 +479,7 @@ impl RmcpClient { session_recovery_lock: Semaphore::new(/*permits*/ 1), elicitation_pause_state: ElicitationPauseState::new(), tool_list_revision: Arc::default(), + cancel_requests_on_drop: AtomicBool::new(false), }) } @@ -535,6 +555,7 @@ impl RmcpClient { session_recovery_lock: Semaphore::new(/*permits*/ 1), elicitation_pause_state: ElicitationPauseState::new(), tool_list_revision: Arc::default(), + cancel_requests_on_drop: AtomicBool::new(false), }) } @@ -643,6 +664,7 @@ impl RmcpClient { session_recovery_lock: Semaphore::new(/*permits*/ 1), elicitation_pause_state: ElicitationPauseState::new(), tool_list_revision: Arc::default(), + cancel_requests_on_drop: AtomicBool::new(false), }) } @@ -717,10 +739,31 @@ impl RmcpClient { if self.requires_read_only_tools { self.apply_read_only_tools_meta(&mut params.get_or_insert_default().meta); } + let cancellation = self.request_cancellation(); let result = self .run_service_operation("tools/list", timeout, move |service| { let params = params.clone(); - async move { service.list_tools(params).await }.boxed() + async move { + if matches!(cancellation, RequestCancellation::Disabled) { + return service.list_tools(params).await; + } + let result = cancellation + .send( + service.peer(), + ClientRequest::ListToolsRequest(ListToolsRequest { + method: Default::default(), + params, + extensions: Default::default(), + }), + rmcp::service::PeerRequestOptions::no_options(), + ) + .await?; + match result { + ServerResult::ListToolsResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + .boxed() }) .await?; self.persist_oauth_tokens().await; @@ -774,10 +817,31 @@ impl RmcpClient { ) -> Result { let params = crate::trace_context::traced_pagination(params); self.refresh_oauth_if_needed().await?; + let cancellation = self.request_cancellation(); let result = self .run_service_operation("resources/list", timeout, move |service| { let params = params.clone(); - async move { service.list_resources(params).await }.boxed() + async move { + if matches!(cancellation, RequestCancellation::Disabled) { + return service.list_resources(params).await; + } + let result = cancellation + .send( + service.peer(), + ClientRequest::ListResourcesRequest(ListResourcesRequest { + method: Default::default(), + params, + extensions: Default::default(), + }), + rmcp::service::PeerRequestOptions::no_options(), + ) + .await?; + match result { + ServerResult::ListResourcesResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + .boxed() }) .await?; self.persist_oauth_tokens().await; @@ -791,10 +855,33 @@ impl RmcpClient { ) -> Result { let params = crate::trace_context::traced_pagination(params); self.refresh_oauth_if_needed().await?; + let cancellation = self.request_cancellation(); let result = self .run_service_operation("resources/templates/list", timeout, move |service| { let params = params.clone(); - async move { service.list_resource_templates(params).await }.boxed() + async move { + if matches!(cancellation, RequestCancellation::Disabled) { + return service.list_resource_templates(params).await; + } + let result = cancellation + .send( + service.peer(), + ClientRequest::ListResourceTemplatesRequest( + ListResourceTemplatesRequest { + method: Default::default(), + params, + extensions: Default::default(), + }, + ), + rmcp::service::PeerRequestOptions::no_options(), + ) + .await?; + match result { + ServerResult::ListResourceTemplatesResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } + .boxed() }) .await?; self.persist_oauth_tokens().await; @@ -809,11 +896,21 @@ impl RmcpClient { let mut params = params; params.meta = crate::trace_context::with_current_trace(params.meta); self.refresh_oauth_if_needed().await?; + let cancellation = self.request_cancellation(); let requested_modern = self.protocol_mode == McpProtocolMode::V20260728; let result = self .run_service_operation("resources/read", timeout, move |service| { let params = params.clone(); async move { + if matches!(cancellation, RequestCancellation::OnDrop) { + return crate::request_input::read_resource( + &service, + service.service(), + params, + cancellation, + ) + .await; + } let modern_session = requested_modern && service.peer().peer_info().is_some_and(|info| { info.protocol_version == ProtocolVersion::V_2026_07_28 @@ -831,6 +928,52 @@ impl RmcpClient { Ok(result) } + /// Reads a resource with inputs and timeout pauses owned by the calling chat. + pub async fn read_resource_with_elicitation( + &self, + mut params: ReadResourceRequestParams, + timeout: Option, + send_elicitation: Arc, + ) -> Result { + params.meta = crate::trace_context::with_current_trace(params.meta); + self.refresh_oauth_if_needed().await?; + let cancellation = self.request_cancellation(); + let pause_state = ElicitationPauseState::new(); + let result = self + .run_service_operation_with_pause_state( + "resources/read", + timeout, + pause_state.clone(), + move |service| { + let params = params.clone(); + let input_service = service + .service() + .for_request(Arc::clone(&send_elicitation), pause_state.clone()); + async move { + if !service.peer().peer_info().is_some_and(|info| { + info.protocol_version == ProtocolVersion::V_2026_07_28 + }) { + return Err(ServiceError::McpError(rmcp::ErrorData::invalid_request( + "request-scoped MCP inputs require protocol 2026-07-28", + /*data*/ None, + ))); + } + crate::request_input::read_resource( + &service, + &input_service, + params, + cancellation, + ) + .await + } + .boxed() + }, + ) + .await?; + self.persist_oauth_tokens().await; + Ok(result) + } + /// Builds metadata against the session that sends each attempt, including a recovery retry. /// The resolver must be safe to invoke again and must retain the call's captured authority. pub async fn call_tool( @@ -840,6 +983,50 @@ impl RmcpClient { meta_for_session: F, timeout: Option, ) -> Result + where + F: Fn(&rmcp::model::ServerCapabilities) -> Result> + Send + Sync, + { + self.call_tool_inner( + name, + arguments, + meta_for_session, + timeout, + /*send_elicitation*/ None, + ) + .await + } + + /// Routes modern tool inputs and timeout pauses to this caller on a shared connection. + /// Legacy connections are rejected because their callbacks belong to connection startup. + pub async fn call_tool_with_elicitation( + &self, + name: String, + arguments: Option, + meta_for_session: F, + timeout: Option, + send_elicitation: Arc, + ) -> Result + where + F: Fn(&rmcp::model::ServerCapabilities) -> Result> + Send + Sync, + { + self.call_tool_inner( + name, + arguments, + meta_for_session, + timeout, + Some(send_elicitation), + ) + .await + } + + async fn call_tool_inner( + &self, + name: String, + arguments: Option, + meta_for_session: F, + timeout: Option, + send_elicitation: Option>, + ) -> Result where F: Fn(&rmcp::model::ServerCapabilities) -> Result> + Send + Sync, { @@ -877,55 +1064,85 @@ impl RmcpClient { rmcp_params.arguments = arguments; let requested_modern = self.protocol_mode == McpProtocolMode::V20260728; let meta_for_session = &meta_for_session; + let pause_state = if send_elicitation.is_some() { + ElicitationPauseState::new() + } else { + self.elicitation_pause_state.clone() + }; match self - .run_service_operation("tools/call", timeout, move |service| { - let mut rmcp_params = rmcp_params.clone(); - async move { - // Resolve from this service, not a cached startup capability or another - // service observed before recovery. Local policy failures never send a call. - let meta = (|| { - let info = service - .peer() - .peer_info() - .context("MCP session has no negotiated server capabilities")?; - let mut meta = match meta_for_session(&info.capabilities)? { - Some(Value::Object(map)) => Some(RequestMetaObject::from(map)), - Some(other) => { - return Err(anyhow!( - "MCP tool request _meta must be a JSON object, got {other}" - )); - } - None => None, + .run_service_operation_with_pause_state( + "tools/call", + timeout, + pause_state.clone(), + move |service| { + let mut rmcp_params = rmcp_params.clone(); + let send_elicitation = send_elicitation.clone(); + let pause_state = pause_state.clone(); + async move { + // Resolve from this service, not a cached startup capability or another + // service observed before recovery. Local policy failures never send a call. + let meta = (|| { + let info = service + .peer() + .peer_info() + .context("MCP session has no negotiated server capabilities")?; + let mut meta = match meta_for_session(&info.capabilities)? { + Some(Value::Object(map)) => Some(RequestMetaObject::from(map)), + Some(other) => { + return Err(anyhow!( + "MCP tool request _meta must be a JSON object, got {other}" + )); + } + None => None, + }; + self.apply_read_only_tools_meta(&mut meta); + Ok(crate::trace_context::with_current_trace(meta)) + })(); + let meta = match meta { + Ok(meta) => meta, + Err(error) => return Ok(Err(error)), }; - self.apply_read_only_tools_meta(&mut meta); - Ok(crate::trace_context::with_current_trace(meta)) - })(); - let meta = match meta { - Ok(meta) => meta, - Err(error) => return Ok(Err(error)), - }; - let modern_session = requested_modern - && service.peer().peer_info().is_some_and(|info| { - info.protocol_version == ProtocolVersion::V_2026_07_28 - }); - if modern_session { - rmcp_params.meta = meta; - return crate::tool_input::call_tool(&service, rmcp_params) + let modern_session = requested_modern + && service.peer().peer_info().is_some_and(|info| { + info.protocol_version == ProtocolVersion::V_2026_07_28 + }); + if modern_session { + rmcp_params.meta = meta; + let input_service = send_elicitation + .map(|sender| service.service().for_request(sender, pause_state)); + return crate::request_input::call_tool( + &service, + input_service.as_ref().unwrap_or_else(|| service.service()), + rmcp_params, + ) .await .map(Ok); - } - let mut options = rmcp::service::PeerRequestOptions::no_options(); - options.meta = meta; - let (_, result) = - crate::tool_request::call_tool(service.peer(), rmcp_params, options) + } + if send_elicitation.is_some() { + return Err(ServiceError::McpError(rmcp::ErrorData::invalid_request( + "request-scoped MCP inputs require protocol 2026-07-28", + /*data*/ None, + ))); + } + let mut options = rmcp::service::PeerRequestOptions::no_options(); + options.meta = meta; + let result = RequestCancellation::OnDrop + .send( + service.peer(), + ClientRequest::CallToolRequest(rmcp::model::CallToolRequest::new( + rmcp_params, + )), + options, + ) .await?; - match result { - ServerResult::CallToolResult(result) => Ok(Ok(result)), - _ => Err(ServiceError::UnexpectedResponse), + match result { + ServerResult::CallToolResult(result) => Ok(Ok(result)), + _ => Err(rmcp::service::ServiceError::UnexpectedResponse), + } } - } - .boxed() - }) + .boxed() + }, + ) .await { Ok(Ok(result)) => { @@ -1005,13 +1222,18 @@ impl RmcpClient { timeout: Option, ) -> Result { self.refresh_oauth_if_needed().await?; + let cancellation = self.request_cancellation(); let response = self .run_service_operation("requests/custom", timeout, move |service| { let params = params.clone(); async move { let request = crate::trace_context::traced_custom_request(method, params); - service - .send_request(ClientRequest::CustomRequest(request)) + cancellation + .send( + service.peer(), + ClientRequest::CustomRequest(request), + rmcp::service::PeerRequestOptions::no_options(), + ) .await } .boxed() @@ -1444,6 +1666,26 @@ impl RmcpClient { timeout: Option, operation: F, ) -> Result + where + F: Fn(Arc>) -> Fut, + Fut: std::future::Future>, + { + self.run_service_operation_with_pause_state( + label, + timeout, + self.elicitation_pause_state.clone(), + operation, + ) + .await + } + + async fn run_service_operation_with_pause_state( + &self, + label: &str, + timeout: Option, + pause_state: ElicitationPauseState, + operation: F, + ) -> Result where F: Fn(Arc>) -> Fut, Fut: std::future::Future>, @@ -1453,7 +1695,7 @@ impl RmcpClient { Arc::clone(&service), label, timeout, - self.elicitation_pause_state.clone(), + pause_state.clone(), &operation, ) .await @@ -1466,7 +1708,7 @@ impl RmcpClient { recovered_service, label, timeout, - self.elicitation_pause_state.clone(), + pause_state, &operation, ) .await diff --git a/codex-rs/rmcp-client/src/tool_input_tests.rs b/codex-rs/rmcp-client/src/tool_input_tests.rs index 306de871e060..5d45b46f3fca 100644 --- a/codex-rs/rmcp-client/src/tool_input_tests.rs +++ b/codex-rs/rmcp-client/src/tool_input_tests.rs @@ -1,8 +1,8 @@ //! Exercises pending MRTR input cleanup over a real duplex transport. use super::ElicitationClientService; +use crate::request_input::call_tool; use crate::rmcp_client::ElicitationPauseState; -use crate::tool_input::call_tool; use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID; use rmcp::RoleServer; use rmcp::model::CallToolRequestParams; @@ -53,7 +53,11 @@ async fn connection_closure_releases_pending_inputs_and_timeout_pause() -> anyho tokio::io::duplex(/*max_buf_size*/ 4096); let client = serve_directly(service, client_transport, /*peer_info*/ None); let mut server = IntoTransport::::into_transport(server_transport); - let call = call_tool(&client, CallToolRequestParams::new("test")); + let call = call_tool( + &client, + client.service(), + CallToolRequestParams::new("test"), + ); tokio::pin!(call); let request = tokio::select! { result = &mut call => panic!("tool completed before input: {result:?}"), diff --git a/codex-rs/rmcp-client/src/tool_request.rs b/codex-rs/rmcp-client/src/tool_request.rs deleted file mode 100644 index 70be1846fb53..000000000000 --- a/codex-rs/rmcp-client/src/tool_request.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Cancel remote tool work when its local response future is dropped. - -use rmcp::Peer; -use rmcp::RoleClient; -use rmcp::model::CallToolRequest; -use rmcp::model::CallToolRequestParams; -use rmcp::model::CancelledNotificationParam; -use rmcp::model::ClientRequest; -use rmcp::model::RequestId; -use rmcp::model::ServerResult; -use rmcp::service::PeerRequestOptions; -use rmcp::service::ServiceError; - -pub(crate) async fn call_tool( - peer: &Peer, - params: CallToolRequestParams, - options: PeerRequestOptions, -) -> Result<(RequestId, ServerResult), ServiceError> { - let handle = peer - .send_request_with_option( - ClientRequest::CallToolRequest(CallToolRequest::new(params)), - options, - ) - .await?; - let id = handle.id.clone(); - let mut cancellation = CancelOnDrop(Some((peer.clone(), id.clone()))); - let result = handle.await_response().await; - cancellation.0 = None; - result.map(|result| (id, result)) -} - -struct CancelOnDrop(Option<(Peer, RequestId)>); - -impl Drop for CancelOnDrop { - fn drop(&mut self) { - if let Some((peer, id)) = self.0.take() - && let Ok(runtime) = tokio::runtime::Handle::try_current() - { - runtime.spawn(async move { - let _ = peer - .notify_cancelled(CancelledNotificationParam::new( - Some(id), - Some("tool call response future dropped".into()), - )) - .await; - }); - } - } -} diff --git a/codex-rs/rmcp-client/src/user_verification_cancellation_tests.rs b/codex-rs/rmcp-client/src/user_verification_cancellation_tests.rs index 30a21bc93026..aab81ce435a0 100644 --- a/codex-rs/rmcp-client/src/user_verification_cancellation_tests.rs +++ b/codex-rs/rmcp-client/src/user_verification_cancellation_tests.rs @@ -5,7 +5,10 @@ use super::ElicitationPauseState; use super::ElicitationResponse; use super::RmcpClient; use crate::InProcessTransportFactory; +use crate::McpProtocolMode; use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID; +use futures::FutureExt; +use futures::TryFutureExt; use futures::future::BoxFuture; use pretty_assertions::assert_eq; use rmcp::RoleServer; @@ -19,9 +22,11 @@ use rmcp::model::ClientJsonRpcMessage; use rmcp::model::ClientNotification; use rmcp::model::ClientRequest; use rmcp::model::CustomRequest; +use rmcp::model::DiscoverResult; use rmcp::model::ElicitationAction; use rmcp::model::InitializeResult; use rmcp::model::ProtocolVersion; +use rmcp::model::ReadResourceRequestParams; use rmcp::model::RequestId; use rmcp::model::ServerJsonRpcMessage; use rmcp::model::ServerNotification; @@ -149,6 +154,78 @@ async fn dropping_tool_call_cancels_remote_request() -> anyhow::Result<()> { .await? } +#[tokio::test] +async fn dropping_shared_resource_and_custom_requests_cancels_the_pending_request() +-> anyhow::Result<()> { + let (servers, mut server_rx) = mpsc::unbounded_channel(); + let mut client = + RmcpClient::new_in_process_client(Arc::new(ElicitationTestTransport { servers })).await?; + client.protocol_mode = McpProtocolMode::V20260728; + let mut server = + IntoTransport::::into_transport(server_rx.recv().await.unwrap()); + let (initialized, discovery) = tokio::join!( + client.initialize( + ClientConfig::default(), + Some(Duration::from_secs(5)), + Box::new(|_, _| panic!("unexpected startup elicitation")), + ), + async { + let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await else { + anyhow::bail!("expected discovery"); + }; + assert!(matches!(request.request, ClientRequest::DiscoverRequest(_))); + let result = DiscoverResult::from_server_info( + vec![ProtocolVersion::V_2026_07_28], + InitializeResult::default(), + ); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(result), + request.id, + )) + .await?; + anyhow::Ok(()) + }, + ); + discovery?; + initialized?; + client.enable_request_cancellation(); + for mut pending in [ + client + .read_resource_with_elicitation( + ReadResourceRequestParams::new("test://pending"), + /*timeout*/ None, + Arc::new(Box::new(|_, _| panic!("unexpected input"))), + ) + .map_ok(|_| ()) + .boxed(), + client + .send_custom_request("test/pending", /*params*/ None) + .map_ok(|_| ()) + .boxed(), + ] { + let request = tokio::select! { + result = &mut pending => panic!("request completed before a response: {result:?}"), + message = timeout(Duration::from_secs(5), server.receive()) => message?, + }; + let Some(ClientJsonRpcMessage::Request(request)) = request else { + anyhow::bail!("expected pending request"); + }; + drop(pending); + let Some(ClientJsonRpcMessage::Notification(notification)) = + timeout(Duration::from_secs(5), server.receive()).await? + else { + anyhow::bail!("expected cancellation"); + }; + let ClientNotification::CancelledNotification(cancelled) = notification.notification else { + anyhow::bail!("expected cancellation"); + }; + assert_eq!(cancelled.params.request_id, Some(request.id)); + } + client.shutdown().await; + Ok(()) +} + #[traced_test] #[tokio::test] async fn recovered_connections_accept_elicitations_with_previously_cancelled_ids() diff --git a/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs b/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs index 5890e3f753a1..91bf1bca0ea5 100644 --- a/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs +++ b/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; @@ -10,6 +11,7 @@ use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::McpProtocolMode; use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::SendElicitation; use futures::FutureExt; use pretty_assertions::assert_eq; use rmcp::model::ClientCapabilities; @@ -133,10 +135,9 @@ async fn create_client( ) .with_protocol_version(ProtocolVersion::V_2025_06_18), Some(Duration::from_secs(5)), - Box::new(move |request_id, request| { + Box::new(move |_, request| { let elicitation_modes = Arc::clone(&elicitation_modes); async move { - assert_eq!(request_id.to_string(), "confirmation"); let Elicitation::Mcp(request) = request else { anyhow::bail!("MRTR input must be a standard MCP elicitation"); }; @@ -219,12 +220,10 @@ async fn modern_sse_input_required_preserves_response_metadata() -> anyhow::Resu } #[tokio::test] -async fn modern_tool_mrtr_drives_form_and_url_elicitation_and_preserves_metadata() +async fn modern_tool_mrtr_routes_concurrent_inputs_to_their_callers_and_preserves_metadata() -> anyhow::Result<()> { for mode in ["form", "url"] { let server = MockServer::start().await; - let calls = Arc::new(Mutex::new(Vec::::new())); - let recorded = Arc::clone(&calls); Mock::given(method("POST")) .and(path("/mcp")) @@ -233,11 +232,7 @@ async fn modern_tool_mrtr_drives_form_and_url_elicitation_and_preserves_metadata match body["method"].as_str() { Some("server/discover") => discover_response(&body), Some("tools/call") => { - recorded.lock().expect("requests lock").push(body.clone()); - assert_eq!( - body.pointer("/params/_meta/requestContext"), - Some(&json!("caller-context")) - ); + let caller = body["params"]["_meta"]["requestContext"].clone(); assert_eq!( body.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion"), Some(&json!(MODERN_VERSION)) @@ -269,7 +264,7 @@ async fn modern_tool_mrtr_drives_form_and_url_elicitation_and_preserves_metadata body.pointer("/params/inputResponses/confirmation"), Some(&json!({ "action": "accept", - "content": {"confirmed": true}, + "content": {"confirmed": true, "caller": caller}, "_meta": {"clientContext": "preserved"}, })) ); @@ -285,35 +280,65 @@ async fn modern_tool_mrtr_drives_form_and_url_elicitation_and_preserves_metadata other => panic!("unexpected MRTR request: {other:?}"), } }) - .expect(3) + .expect(8) .mount(&server) .await; - let elicitation_modes = Arc::new(Mutex::new(Vec::new())); - let client = create_client(&server, Arc::clone(&elicitation_modes)).await?; - let result = client - .call_tool( + let client = create_client(&server, Arc::default()).await?; + let other_client = create_client(&server, Arc::default()).await?; + let inputs = Arc::new(Mutex::new(Vec::new())); + let callers = [ + (&client, "first"), + (&client, "second"), + (&other_client, "other connection"), + ]; + let results = futures::future::try_join_all(callers.map(|(client, caller)| { + let inputs = Arc::clone(&inputs); + let sender: SendElicitation = Box::new(move |id, request| { + inputs.lock().unwrap().push((caller, id)); + let Elicitation::Mcp(request) = request else { + panic!("expected standard MCP input"); + }; + let meta = match request { + ElicitRequestParams::FormElicitationParams { meta, .. } + | ElicitRequestParams::UrlElicitationParams { meta, .. } => meta, + _ => panic!("expected form or URL input"), + }; + assert_eq!( + meta.and_then(|meta| meta.get("inputContext").cloned()), + Some(json!("server-context")) + ); + futures::future::ready(Ok(ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(json!({"confirmed": true, "caller": caller})), + meta: Some(json!({"clientContext": "preserved"})), + })) + .boxed() + }); + client.call_tool_with_elicitation( "confirm".into(), Some(json!({"mode": mode})), - |_| Ok(Some(json!({"requestContext": "caller-context"}))), + move |_| Ok(Some(json!({"requestContext": caller}))), Some(Duration::from_secs(5)), + Arc::new(sender), ) - .await?; - assert_eq!( - result.content[0].as_text().map(|text| text.text.as_str()), - Some("MRTR completed") - ); - assert_eq!( - *elicitation_modes.lock().expect("elicitation lock"), - vec![mode] - ); + })) + .await?; + for result in results { + assert_eq!( + result.content[0].as_text().map(|text| text.text.as_str()), + Some("MRTR completed") + ); + } { - let calls = calls.lock().expect("requests lock"); - assert_eq!(calls.len(), 2); - assert_ne!(calls[0]["id"], calls[1]["id"]); + let inputs = inputs.lock().unwrap(); + assert_eq!(inputs.len(), 3); + let ids = inputs.iter().map(|(_, id)| id).collect::>(); + assert_eq!(ids.len(), inputs.len()); } client.shutdown().await; + other_client.shutdown().await; } Ok(()) } @@ -548,11 +573,26 @@ async fn modern_resource_mrtr_retries_with_opaque_request_state() -> anyhow::Res .await; let elicitation_modes = Arc::new(Mutex::new(Vec::new())); - let client = create_client(&server, Arc::clone(&elicitation_modes)).await?; + let client = create_client(&server, Arc::default()).await?; + let observed_modes = Arc::clone(&elicitation_modes); + let sender: SendElicitation = Box::new(move |_, request| { + assert!(matches!( + request, + Elicitation::Mcp(ElicitRequestParams::FormElicitationParams { .. }) + )); + observed_modes.lock().unwrap().push("form"); + futures::future::ready(Ok(ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(json!({"confirmed": true})), + meta: Some(json!({"clientContext": "preserved"})), + })) + .boxed() + }); let result = client - .read_resource( + .read_resource_with_elicitation( ReadResourceRequestParams::new("memo://requires-confirmation"), Some(Duration::from_secs(5)), + Arc::new(sender), ) .await?; assert_eq!(result.contents.len(), 1); diff --git a/codex-rs/rmcp-client/tests/mcp_2026_mrtr/native_verification_tests.rs b/codex-rs/rmcp-client/tests/mcp_2026_mrtr/native_verification_tests.rs index 705867acef5f..de78ad7b5c5f 100644 --- a/codex-rs/rmcp-client/tests/mcp_2026_mrtr/native_verification_tests.rs +++ b/codex-rs/rmcp-client/tests/mcp_2026_mrtr/native_verification_tests.rs @@ -465,7 +465,9 @@ async fn native_mrtr_does_not_restart_after_continuation_session_expiry() -> any .await?; let error = call(&client).await.unwrap_err(); assert!( - error.to_string().contains("the tool was not restarted"), + error + .to_string() + .contains("the operation was not restarted"), "{error}" ); client.shutdown().await; @@ -512,7 +514,9 @@ async fn native_mrtr_does_not_restart_after_proof_then_state_only_round() -> any .await?; let error = call(&client).await.unwrap_err(); assert!( - error.to_string().contains("the tool was not restarted"), + error + .to_string() + .contains("the operation was not restarted"), "{error}" ); client.shutdown().await; @@ -521,7 +525,7 @@ async fn native_mrtr_does_not_restart_after_proof_then_state_only_round() -> any } #[tokio::test] -async fn native_mrtr_concurrent_prompts_have_independent_cancellation() -> anyhow::Result<()> { +async fn native_mrtr_shared_calls_isolate_inputs_cancellation_and_timeouts() -> anyhow::Result<()> { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/mcp")) @@ -529,6 +533,10 @@ async fn native_mrtr_concurrent_prompts_have_independent_cancellation() -> anyho let body: Value = request.body_json().unwrap(); match body["method"].as_str() { Some("server/discover") => discover_response(&body), + Some("tools/call") if body["params"]["name"] == "slow" => { + result_response(&body, json!({"resultType": "complete", "content": []})) + .set_delay(Duration::from_secs(1)) + } Some("tools/call") if body.pointer("/params/inputResponses").is_none() => { result_response(&body, input_required(native_request())) } @@ -542,31 +550,62 @@ async fn native_mrtr_concurrent_prompts_have_independent_cancellation() -> anyho other => panic!("unexpected request: {other:?}"), } }) - .expect(/*r*/ 4) + .expect(/*r*/ 5) .mount(&server) .await; let (tx, mut rx) = mpsc::unbounded_channel(); let client = client( &server, capabilities(), - Box::new(move |id, _| { - let (reply_tx, reply_rx) = oneshot::channel(); - tx.send((id, reply_tx)).unwrap(); - Box::pin(async move { Ok(reply_rx.await?) }) - }), + Box::new(|_, _| panic!("shared input must use the calling chat's callback")), ) .await?; - let first_client = Arc::clone(&client); - let first_call = tokio::spawn(async move { call(&first_client).await }); - let (first_id, mut first_reply) = timeout(Duration::from_secs(/*secs*/ 5), rx.recv()) - .await? - .unwrap(); - let second_client = Arc::clone(&client); - let second_call = tokio::spawn(async move { call(&second_client).await }); - let (second_id, second_reply) = timeout(Duration::from_secs(/*secs*/ 5), rx.recv()) - .await? - .unwrap(); + let launch = |caller| { + let client = Arc::clone(&client); + let tx = tx.clone(); + tokio::spawn(async move { + let sender: SendElicitation = Box::new(move |id, _| { + let (reply_tx, reply_rx) = oneshot::channel(); + tx.send((caller, id, reply_tx)).unwrap(); + Box::pin(async move { Ok(reply_rx.await?) }) + }); + client + .call_tool_with_elicitation( + "verified_operation".into(), + /*arguments*/ None, + |_| Ok(None), + Some(Duration::from_secs(5)), + Arc::new(sender), + ) + .await + }) + }; + let first_call = launch("first"); + let (first_caller, first_id, mut first_reply) = + timeout(Duration::from_secs(/*secs*/ 5), rx.recv()) + .await? + .unwrap(); + let second_call = launch("second"); + let (second_caller, second_id, second_reply) = + timeout(Duration::from_secs(/*secs*/ 5), rx.recv()) + .await? + .unwrap(); assert_ne!(first_id, second_id); + assert_eq!((first_caller, second_caller), ("first", "second")); + let timeout_error = client + .call_tool_with_elicitation( + "slow".into(), + /*arguments*/ None, + |_| Ok(None), + Some(Duration::from_millis(100)), + Arc::new(Box::new(|_, _| panic!("slow call must not ask for input"))), + ) + .await + .unwrap_err(); + assert!( + timeout_error.to_string().contains("timed out"), + "{timeout_error}" + ); first_call.abort(); assert!(first_call.await.unwrap_err().is_cancelled()); timeout(Duration::from_secs(/*secs*/ 5), first_reply.closed()).await?; @@ -575,7 +614,7 @@ async fn native_mrtr_concurrent_prompts_have_independent_cancellation() -> anyho .send(response(ElicitationAction::Cancel, Value::Null)) .unwrap(); assert_eq!( - timeout(Duration::from_secs(/*secs*/ 5), second_call).await???, + serde_json::to_value(timeout(Duration::from_secs(/*secs*/ 5), second_call).await???)?, json!({"resultType": "complete", "content": []}) ); client.shutdown().await; From 60e4d10ad973dc1b5ec859dbae0e56ae2d5ec2fd Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 23 Oct 2026 23:54:13 +0000 Subject: [PATCH 026/287] Clarify skill toggle summaries in the TUI (#57197) ## What changed Omit zero-count actions and use singular or plural wording when summarizing skill changes. For example, show `Disabled 1 skill` instead of `0 skills enabled, 1 skills disabled`. ## Testing Add a snapshot test covering enable-only, disable-only, mixed, and unchanged skill states, including no summary when nothing changes. GitOrigin-RevId: ff68c799f29ceb8736e6941778d29a3f8d500b70 --- codex-rs/tui/src/chatwidget/skills.rs | 14 +++-- ...twidget__tests__manage_skills_summary.snap | 8 +++ codex-rs/tui/src/chatwidget/tests.rs | 2 + .../chatwidget/tests/skills_summary_tests.rs | 54 +++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manage_skills_summary.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/skills_summary_tests.rs diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index f505fdde3841..5c1350cc6cb3 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -130,10 +130,16 @@ impl ChatWidget { if enabled_count == 0 && disabled_count == 0 { return; } - self.add_info_message( - format!("{enabled_count} skills enabled, {disabled_count} skills disabled"), - /*hint*/ None, - ); + let enabled_suffix = if enabled_count == 1 { "" } else { "s" }; + let disabled_suffix = if disabled_count == 1 { "" } else { "s" }; + let summary = match (enabled_count, disabled_count) { + (0, _) => format!("Disabled {disabled_count} skill{disabled_suffix}"), + (_, 0) => format!("Enabled {enabled_count} skill{enabled_suffix}"), + _ => format!( + "Enabled {enabled_count} skill{enabled_suffix}, disabled {disabled_count} skill{disabled_suffix}" + ), + }; + self.add_info_message(summary, /*hint*/ None); } pub(crate) fn set_skills_from_response(&mut self, response: &SkillsListResponse) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manage_skills_summary.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manage_skills_summary.snap new file mode 100644 index 000000000000..d16583522354 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manage_skills_summary.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/skills_summary_tests.rs +expression: "summaries.join(\"\\n\")" +--- +enable: • Enabled 2 skills +disable: • Disabled 1 skill +mixed: • Enabled 2 skills, disabled 1 skill +no changes: diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index aae8906ab930..42f292b52b0a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -289,6 +289,8 @@ mod review_picker_tests; #[path = "tests/session_model_selection_tests.rs"] mod session_model_selection_tests; mod side; +#[path = "tests/skills_summary_tests.rs"] +mod skills_summary_tests; mod slash_commands; #[path = "tests/sparkle_submission_tests.rs"] mod sparkle_submission_tests; diff --git a/codex-rs/tui/src/chatwidget/tests/skills_summary_tests.rs b/codex-rs/tui/src/chatwidget/tests/skills_summary_tests.rs new file mode 100644 index 000000000000..69d13123a441 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/skills_summary_tests.rs @@ -0,0 +1,54 @@ +#![cfg(test)] + +//! Snapshots change-count wording and silence when skill state is unchanged. + +use super::*; + +#[tokio::test] +async fn manage_skills_summary_snapshot() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + let initial = [false, false, true]; + chat.skills_all = initial + .iter() + .enumerate() + .map(|(index, &enabled)| SkillMetadata { + name: format!("skill-{index}"), + description: "Summary fixture".to_string(), + short_description: None, + interface: None, + dependencies: None, + path: test_path_buf(&format!("/tmp/skills/skill-{index}/SKILL.md")) + .abs() + .into(), + scope: crate::test_support::skill_scope_user(), + enabled, + plugin_id: None, + }) + .collect(); + drain_insert_history(&mut rx); + let mut summaries = Vec::new(); + for (name, current) in [ + ("enable", [true, true, true]), + ("disable", [false, false, false]), + ("mixed", [true, true, false]), + ("no changes", initial), + ] { + chat.skills_initial_state = Some( + chat.skills_all + .iter() + .zip(initial) + .map(|(skill, enabled)| (skill.path.to_inferred_path_uri().unwrap(), enabled)) + .collect(), + ); + for (skill, enabled) in chat.skills_all.iter_mut().zip(current) { + skill.enabled = enabled; + } + chat.handle_manage_skills_closed(); + let lines = drain_insert_history(&mut rx).concat(); + summaries.push(format!( + "{name}: {}", + lines_to_single_string(&lines).trim_end() + )); + } + assert_chatwidget_snapshot!("manage_skills_summary", summaries.join("\n")); +} From fe15bd38def0fddabf9b54ab0ccc86e47af4b77c Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Fri, 23 Oct 2026 23:59:12 +0000 Subject: [PATCH 027/287] Report the local command working directory in PreToolUse hooks (#57198) ## Why `PreToolUse` hooks receive the host working directory even when a local `exec_command` call specifies a different `workdir`. This prevents hooks from seeing the directory in which the command will execute. ## What changed - Share working-directory resolution between command execution and hook input, using the selected environment and the command's `workdir`. - Report the resolved local directory in the hook input's `cwd`. Keep hook processes in the host directory and retain the host `cwd` for remote commands. - Reject invalid environment selections or paths before running hooks and record failures in tool-result telemetry. ## Testing Add unit and integration coverage for default, empty, relative, and absolute working directories; Windows path conventions; remote commands; hook denial and input rewriting; and invalid arguments that fail without running hooks. GitOrigin-RevId: 97f71f6d3e21a5d95a7abf5384f06f4993241797 --- codex-rs/core/src/hook_runtime.rs | 2 + .../core/src/tools/handlers/unified_exec.rs | 16 ++ .../handlers/unified_exec/exec_command.rs | 32 ++- .../src/tools/handlers/unified_exec_tests.rs | 73 ++++++ codex-rs/core/src/tools/registry.rs | 27 +++ codex-rs/core/tests/suite/hooks.rs | 4 + codex-rs/core/tests/suite/hooks_cwd_tests.rs | 215 ++++++++++++++++++ codex-rs/hooks/src/engine/mod_tests.rs | 9 + codex-rs/hooks/src/events/pre_tool_use.rs | 30 ++- 9 files changed, 395 insertions(+), 13 deletions(-) create mode 100644 codex-rs/core/tests/suite/hooks_cwd_tests.rs diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index e03e9861ea68..bea5f8cae991 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -196,6 +196,7 @@ pub(crate) async fn run_pre_tool_use_hooks( tool_use_id: String, tool_name: &HookToolName, tool_input: &Value, + tool_cwd: Option, target_environments: &[&str], ) -> PreToolUseHookResult { let turn_context = &step_context.turn; @@ -204,6 +205,7 @@ pub(crate) async fn run_pre_tool_use_hooks( turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), cwd: tool_hook_cwd(&step_context.environments, turn_context), + tool_cwd, transcript_path: sess.hook_transcript_path().await, model: step_context.settings.model_info.slug.clone(), permission_mode: hook_permission_mode(step_context.settings.approval_policy()), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 50faf5259ae7..3448b427f051 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,5 +1,6 @@ use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; +use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; use crate::shell::ShellInvocation; use crate::shell::get_shell_by_model_provided_path; @@ -12,6 +13,7 @@ use crate::tools::handlers::resolve_tool_environment; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use codex_protocol::models::AdditionalPermissionProfile; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use serde::Deserialize; use std::path::PathBuf; @@ -93,6 +95,20 @@ struct ExecCommandWorkdirArgs { workdir: Option, } +impl ExecCommandWorkdirArgs { + /// Share cwd resolution between hooks and execution after environment selection. + fn resolve_cwd(&self, environment: &TurnEnvironment) -> Result { + self.workdir + .as_deref() + .filter(|workdir| !workdir.is_empty()) + .map_or_else( + || Ok(environment.cwd().clone()), + |workdir| environment.cwd().join(workdir), + ) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) + } +} + fn default_exec_yield_time_ms() -> u64 { 10_000 } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 1a188c90a1cd..e4dbf146e9f2 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -47,6 +47,7 @@ use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_output_truncation::approx_token_count; use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use codex_utils_string::truncate_middle_chars; use super::super::shell_spec::CommandToolOptions; @@ -183,16 +184,7 @@ impl ExecCommandHandler { ); let workdir_args: ExecCommandWorkdirArgs = parse_arguments(&arguments)?; let turn_environment = selected_executor(&environments, &step_context.environments)?; - let native_environment_cwd = turn_environment.cwd().clone(); - let cwd = workdir_args - .workdir - .as_deref() - .filter(|workdir| !workdir.is_empty()) - .map_or_else( - || Ok(native_environment_cwd.clone()), - |workdir| native_environment_cwd.join(workdir), - ) - .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let cwd = workdir_args.resolve_cwd(turn_environment)?; let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); @@ -408,7 +400,7 @@ impl ExecCommandHandler { yield_time_ms, max_output_tokens, cwd, - sandbox_cwd: native_environment_cwd, + sandbox_cwd: turn_environment.cwd().clone(), turn_environment: turn_environment.clone(), network: context.step_context.turn.network.clone(), tty, @@ -522,6 +514,24 @@ impl CoreToolRuntime for ExecCommandHandler { }) } + fn pre_tool_use_cwd( + &self, + invocation: &ToolInvocation, + ) -> Result, FunctionCallError> { + let ToolPayload::Function { arguments } = &invocation.payload else { + return Ok(None); + }; + let args: ExecCommandWorkdirArgs = parse_arguments(arguments)?; + let environment = selected_executor( + &invocation.environments, + &invocation.step_context.environments, + )?; + let cwd = args.resolve_cwd(environment)?; + // Hook scripts execute on the host. Remote command paths cannot replace + // the host cwd in hook input, even when both machines use the same OS. + Ok((!environment.environment.is_remote()).then_some(cwd)) + } + fn with_updated_hook_input( &self, mut invocation: ToolInvocation, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index ee8b481bba9e..d1a76fad9506 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -489,6 +489,79 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() { ); } +#[tokio::test] +async fn exec_command_pre_tool_use_cwd_resolves_selected_environment() { + let (session, mut turn) = make_session_and_context().await; + let mut selected = turn.initial_environments.environments[0].clone(); + let TurnEnvironmentState::Ready(environment) = &mut selected else { + panic!("ready environment"); + }; + environment.selection.environment_id = "other".to_string(); + environment.selection.cwd = PathUri::parse("file:///C:/repo-B").unwrap(); + turn.initial_environments.environments.push(selected); + let turn = Arc::new(turn); + let mut invocation = ToolInvocation { + session: session.into(), + step_context: StepContext::for_test(Arc::clone(&turn)), + turn, + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "hook-cwd".to_string(), + tool_name: codex_tools::ToolName::plain("exec_command"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: String::new(), + }, + environments: Default::default(), + }; + for (workdir, expected) in [ + (None, "file:///C:/repo-B"), + (Some(""), "file:///C:/repo-B"), + (Some(r"nested\..\target"), "file:///C:/repo-B/target"), + (Some(r"D:\repo-C"), "file:///D:/repo-C"), + ] { + invocation.payload = ToolPayload::Function { + arguments: serde_json::json!({ + "cmd": "echo hello", + "environment_id": "other", + "workdir": workdir, + }) + .to_string(), + }; + let handler = ExecCommandHandler::default(); + invocation.environments = handler.environments_for_input(&invocation).unwrap(); + assert_eq!( + handler.pre_tool_use_cwd(&invocation).unwrap(), + Some(PathUri::parse(expected).unwrap()), + ); + } +} + +#[tokio::test] +async fn exec_command_pre_tool_use_cwd_rejects_invalid_environment_and_path() { + for args in [ + serde_json::json!({ "cmd": "echo hello", "environment_id": "missing" }), + serde_json::json!({ "cmd": "echo hello", "workdir": "\u{0}" }), + ] { + let mut invocation = invocation_for_payload( + "exec_command", + "invalid-cwd", + ToolPayload::Function { + arguments: args.to_string(), + }, + ) + .await; + let handler = ExecCommandHandler::default(); + let cwd = handler + .environments_for_input(&invocation) + .and_then(|environments| { + invocation.environments = environments; + handler.pre_tool_use_cwd(&invocation) + }); + assert!(matches!(cwd, Err(FunctionCallError::RespondToModel(_)))); + } +} + #[tokio::test] async fn exec_command_pre_tool_use_payload_skips_write_stdin() { let payload = ToolPayload::Function { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index c60feef0be83..b42bce6f238d 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -43,6 +43,7 @@ use codex_tools::FunctionsNamespaceFunctionPrefixes; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use indexmap::IndexMap; use indexmap::map::Entry; @@ -175,6 +176,16 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { }) } + /// Resolves a local execution cwd exposed in `PreToolUse` stdin, when the tool + /// supports a per-call working directory. Remote commands return `None` to + /// retain the host cwd. This does not move the hook process. + fn pre_tool_use_cwd( + &self, + _invocation: &ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(None) + } + /// Rebuilds a tool invocation from hook-facing `tool_input`. /// /// Tools that opt into input-rewriting hooks should invert the same stable @@ -775,6 +786,17 @@ impl ToolRegistry { } let fail_before_execution = async |invocation: &ToolInvocation, err: FunctionCallError| { + let log_payload = tool_log_payload(&invocation.payload, &invocation.source); + otel.tool_result_with_tags( + &tool_name, + &call_id_owned, + log_payload.as_ref(), + Duration::ZERO, + /*success*/ false, + &err.to_string(), + &tool_result_tags, + &extra_trace_fields, + ); if tool.is_builtin_control_tool() { let mut analytics = ControlToolCallGuard::new(invocation); analytics.finish(ControlToolCallStatus::Failed); @@ -799,12 +821,17 @@ impl ToolRegistry { Err(err) => return Err(fail_before_execution(&invocation, err).await), }; if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) { + let tool_cwd = match tool.pre_tool_use_cwd(&invocation) { + Ok(cwd) => cwd, + Err(err) => return Err(fail_before_execution(&invocation, err).await), + }; match run_pre_tool_use_hooks( &invocation.session, invocation.step_context.as_ref(), invocation.call_id.clone(), &pre_tool_use_payload.tool_name, &pre_tool_use_payload.tool_input, + tool_cwd, &invocation.environments.hook_ids(), ) .or_cancel_tool(&cancellation_token) diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 1f7624405102..a31b55a5d41d 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -91,6 +91,10 @@ const BLOCKED_PROMPT_CONTEXT: &str = "Remember the blocked lighthouse note."; const PERMISSION_REQUEST_HOOK_MATCHER: &str = "^Bash$"; const PERMISSION_REQUEST_ALLOW_REASON: &str = "should not be used for allow"; +#[cfg(test)] +#[path = "hooks_cwd_tests.rs"] +mod cwd_tests; + #[tokio::test] async fn managed_hook_discovery_failure_rejects_session_startup_when_hooks_enabled() -> Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/tests/suite/hooks_cwd_tests.rs b/codex-rs/core/tests/suite/hooks_cwd_tests.rs new file mode 100644 index 000000000000..de371808bac3 --- /dev/null +++ b/codex-rs/core/tests/suite/hooks_cwd_tests.rs @@ -0,0 +1,215 @@ +#![cfg(test)] + +//! PreToolUse reports local command cwd and retains host cwd for remote commands. + +use super::*; +use codex_exec_server::CreateDirectoryOptions; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::test_codex::local; +use pretty_assertions::assert_eq; +use test_case::test_case; + +#[test_case(None, "allow"; "default")] +#[test_case(Some(""), "allow"; "empty")] +#[test_case(Some("nested/../nested"), "allow"; "relative")] +#[test_case(Some("absolute"), "allow"; "absolute")] +#[test_case(Some("nested"), "json_deny"; "denied")] +#[test_case(Some("nested"), "rewrite"; "rewritten")] +#[tokio::test] +async fn pre_tool_use_cwd_matches_execution( + workdir: Option<&str>, + mode: &'static str, +) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let test = test_codex() + .with_pre_build_hook(move |home| { + if mode == "rewrite" { + write_updating_pre_tool_use_hook( + home, + "^Bash$", + &json!({ "command": "cat rewritten-marker.txt" }), + ) + .expect("write rewriting hook"); + } else { + write_pre_tool_use_hook(home, Some("^Bash$"), mode, "blocked by cwd hook") + .expect("write recording hook"); + } + let script_path = home.join("pre_tool_use_hook.py"); + let script = fs::read_to_string(&script_path) + .expect("read hook script") + .replace( + "payload = json.load(sys.stdin)", + "payload = json.load(sys.stdin)\npayload['process_cwd'] = str(Path.cwd())", + ); + fs::write(script_path, script).expect("record hook process cwd"); + }) + .with_config(trust_discovered_hooks) + .build_with_remote_and_local_env(&server) + .await?; + + // Hooks run on the host even when commands use the remote test executor. + let local_hook_directory = TempDir::new()?; + let mut environments = vec![test.executor_environment().selection().clone()]; + let hook_cwd = if test.executor_environment().environment().is_remote() { + let cwd = AbsolutePathBuf::try_from(local_hook_directory.path())?; + environments.push(local(cwd.clone())); + cwd + } else { + test.config.cwd.clone() + }; + let root = test.executor_environment().selection().cwd.clone(); + let nested = root.join("nested")?; + test.fs() + .create_directory( + &nested, + CreateDirectoryOptions { + recursive: true, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + for (directory, marker) in [(&root, "root-marker"), (&nested, "nested-marker")] { + for filename in ["cwd-marker.txt", "rewritten-marker.txt"] { + test.fs() + .write_file( + &directory.join(filename)?, + format!("{marker}:{filename}").into_bytes(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + } + } + let mut args = json!({ "cmd": "cat cwd-marker.txt", "login": false }); + if let Some(workdir) = workdir { + args["workdir"] = if workdir == "absolute" { + json!(nested.inferred_native_path_string()) + } else { + json!(workdir) + }; + } + let call_id = "hook-cwd"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &args.to_string()), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + test.submit_turn_with_environments( + "read the marker in the requested directory", + Some(environments), + ) + .await?; + + let inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + let expected_cwd = if workdir.is_none_or(str::is_empty) { + &root + } else { + &nested + }; + assert_eq!( + inputs + .iter() + .map(|input| json!({ + "cwd": input["cwd"], + "process_cwd": input["process_cwd"], + "tool_input": input["tool_input"], + })) + .collect::>(), + vec![json!({ + "cwd": if test.executor_environment().environment().is_remote() { + dunce::canonicalize(&hook_cwd)?.to_string_lossy().into_owned() + } else { + expected_cwd.inferred_native_path_string() + }, + "process_cwd": dunce::canonicalize(&hook_cwd)?.to_string_lossy(), + "tool_input": { "command": "cat cwd-marker.txt" }, + })], + ); + let requests = responses.requests(); + let output = requests[1].function_call_output(call_id); + let output = output["output"].as_str().context("command output")?; + if mode == "json_deny" { + assert!(output.contains("Command blocked by PreToolUse hook: blocked by cwd hook")); + } else { + let marker = if expected_cwd == &root { + "root-marker" + } else { + "nested-marker" + }; + let filename = if mode == "rewrite" { + "rewritten-marker.txt" + } else { + "cwd-marker.txt" + }; + assert!(output.contains(&format!("{marker}:{filename}")), "{output}"); + } + Ok(()) +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn invalid_execution_cwd_records_failure_without_running_hooks() -> Result<()> { + skip_if_no_network!(Ok(())); + for (field, value) in [("environment_id", "missing-environment"), ("workdir", "\0")] { + let server = start_mock_server().await; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + write_pre_tool_use_hook(home, Some("^Bash$"), "allow", "") + .expect("write recording hook"); + }) + .with_config(trust_discovered_hooks); + let test = builder.build_with_auto_env(&server).await?; + let args = json!({ "cmd": "git version", field: value }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(field, "exec_command", &args.to_string()), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + test.submit_turn("try the invalid shell arguments").await?; + assert!( + !test + .codex_home_path() + .join("pre_tool_use_hook_log.jsonl") + .exists() + ); + let output = responses.requests()[1].function_call_output(field); + let output = output["output"].as_str().context("tool failure")?; + assert!(!output.is_empty()); + logs_assert(|lines: &[&str]| { + lines + .iter() + .any(|line| { + line.contains("codex.tool_result") + && line.contains(&format!("call_id={field}")) + && line.contains("success=false") + }) + .then_some(()) + .ok_or_else(|| format!("missing failure telemetry for {field}")) + }); + } + Ok(()) +} diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index a70e2ad91da3..4e93a0075d9a 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -646,6 +646,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: turn_id: "turn-1".to_string(), subagent: None, cwd: cwd.clone(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -666,6 +667,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: turn_id: "turn-1".to_string(), subagent: None, cwd, + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -740,6 +742,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -1142,6 +1145,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { turn_id: "turn-1".to_string(), subagent: None, cwd, + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -1575,6 +1579,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { turn_id: "turn-1".to_string(), subagent: None, cwd, + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -1672,6 +1677,7 @@ fn profile_user_layers_load_shared_hooks_json_once() { turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -2112,6 +2118,7 @@ print(json.dumps({ turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -2147,6 +2154,7 @@ print(json.dumps({ turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), @@ -2853,6 +2861,7 @@ async fn mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions() { turn_id: "turn-1".to_string(), subagent: None, cwd: cwd(), + tool_cwd: None, transcript_path: None, model: "gpt-test".to_string(), permission_mode: "default".to_string(), diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 505eeae1da97..30d90cfa3fe1 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use serde_json::Value; use super::common; @@ -26,6 +27,9 @@ pub struct PreToolUseRequest { pub turn_id: String, pub subagent: Option, pub cwd: AbsolutePathBuf, + /// Local tool cwd for hook stdin. Remote commands leave this unset to retain + /// the host cwd. The hook process still runs in `cwd`. + pub tool_cwd: Option, pub transcript_path: Option, pub model: String, pub permission_mode: String, @@ -171,7 +175,9 @@ fn latest_updated_input( /// Handler selection may include internal matcher aliases, but hook stdin keeps /// the canonical `tool_name` so audit logs and downstream policy decisions stay /// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP -/// tools pass their resolved JSON arguments. +/// tools pass their resolved JSON arguments. Local commands can expose their +/// execution cwd here, independently of the directory used to launch hooks. +/// Remote commands retain the host cwd; remote cwd reporting is deferred. fn command_input(request: &PreToolUseRequest) -> Result { let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_value(PreToolUseCommandInput { @@ -180,7 +186,10 @@ fn command_input(request: &PreToolUseRequest) -> Result Date: Sat, 24 Oct 2026 00:03:34 +0000 Subject: [PATCH 028/287] Truncate rollout JSON summaries at UTF-8 character boundaries (#57199) ## Why Truncating a JSON summary at the 240-byte limit can panic when the cutoff falls inside a multibyte UTF-8 character. ## What changed Truncate at the nearest preceding character boundary before appending `...`. ## Testing Extend the JSON tool-call reuse test with multibyte arguments and assert the truncated summary and original payload reference. GitOrigin-RevId: e4b2af17eb6e3abaeb042772944305c6e1744bdf --- .../src/reducer/conversation/normalize.rs | 2 +- .../src/reducer/conversation_tests.rs | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs index 124d19ebde3b..6171dd87e2b5 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs @@ -533,7 +533,7 @@ fn summarize_json(value: &Value) -> String { let mut summary = serde_json::to_string(value).unwrap_or_else(|_| "".to_string()); if summary.len() > MAX_JSON_SUMMARY_LEN { - summary.truncate(MAX_JSON_SUMMARY_LEN); + summary.truncate(summary.floor_char_boundary(MAX_JSON_SUMMARY_LEN)); summary.push_str("..."); } summary diff --git a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs index aad48dd3e19a..b6a8ac38d483 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs @@ -290,6 +290,8 @@ fn later_full_request_reuses_prior_json_tool_call_by_position() -> anyhow::Resul let temp = TempDir::new()?; let writer = create_started_writer(&temp)?; start_turn(&writer, "turn-1")?; + // The display summary's byte limit falls inside a three-byte character. + let arguments = json!({"cmd": "界".repeat(100)}).to_string(); let request = writer.write_json_payload( RawPayloadKind::InferenceRequest, @@ -306,11 +308,12 @@ fn later_full_request_reuses_prior_json_tool_call_by_position() -> anyhow::Resul "output_items": [{ "type": "function_call", "name": "shell", - "arguments": "{\"cmd\":\"cargo test\"}", + "arguments": arguments, "call_id": "call-1" }] }), )?; + let response_payload_id = response.raw_payload_id.clone(); append_inference_completion(&writer, "inference-1", "resp-1", response)?; start_turn(&writer, "turn-2")?; @@ -322,7 +325,7 @@ fn later_full_request_reuses_prior_json_tool_call_by_position() -> anyhow::Resul { "type": "function_call", "name": "shell", - "arguments": "{\"cmd\":\"cargo test\"}", + "arguments": arguments, "call_id": "call-1" } ] @@ -342,6 +345,15 @@ fn later_full_request_reuses_prior_json_tool_call_by_position() -> anyhow::Resul ], ); assert_eq!(rollout.conversation_items.len(), 2); + assert_eq!( + rollout.conversation_items[&first.response_item_ids[0]].body, + ConversationBody { + parts: vec![ConversationPart::Json { + summary: format!("{{\"cmd\":\"{}...", "界".repeat(77)), + raw_payload_id: response_payload_id, + }], + }, + ); Ok(()) } From f371f9701fb0937d790db2c930ef5f7268498a0d Mon Sep 17 00:00:00 2001 From: taohong-oai Date: Sat, 24 Oct 2026 00:08:45 +0000 Subject: [PATCH 029/287] Capture direct-tool critical paths behind a feature flag (#57201) ## What changed Add the default-disabled `critical_path_timing` feature to capture dependency paths for root-agent turns. Connect direct tool dispatch, execution-lock handoffs, and completed tool batches to turn timing so overlapping work shares its common history without double counting. Omit captures for unsupported dependencies, including CodeMode, agent communication, approval review, and user waits. Settle aborted tasks before freezing timing, invalidate captures on forced teardown, and apply runtime feature changes only to future turns. ## Testing Add coverage for serial and overlapping tool dispatch, execution-lock dependency handoffs, cancellation, unsupported paths, frozen timing after completion, and configuration refresh boundaries. GitOrigin-RevId: 71458b654b1c4a05799886928b449c948ea33109 --- codex-rs/Cargo.lock | 5 + codex-rs/Cargo.toml | 1 + codex-rs/core/Cargo.toml | 2 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/guardian/decision.rs | 155 ++++++----- .../src/guardian/decision_timing_tests.rs | 63 +++++ codex-rs/core/src/guardian/mod.rs | 4 + codex-rs/core/src/session/config_refresh.rs | 12 +- .../core/src/session/config_refresh_tests.rs | 91 +++++++ .../src/session/elicitation_holders_tests.rs | 57 +++- codex-rs/core/src/session/mcp.rs | 9 + codex-rs/core/src/session/mod.rs | 23 ++ codex-rs/core/src/session/tests.rs | 63 ++++- codex-rs/core/src/session/turn.rs | 4 +- codex-rs/core/src/tasks/mod.rs | 6 + codex-rs/core/src/tasks/mod_tests.rs | 70 +++++ codex-rs/core/src/tasks/regular.rs | 8 + codex-rs/core/src/test_support.rs | 32 +++ codex-rs/core/src/tools/parallel.rs | 152 ++++++++++- codex-rs/core/src/turn_timing.rs | 81 +++++- codex-rs/core/src/turn_timing_tests.rs | 107 +++++++- codex-rs/core/tests/suite/auto_review.rs | 30 ++- .../core/tests/suite/critical_path_runtime.rs | 251 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/turn_phase_trace.rs | 51 +++- codex-rs/direct-tool-timing/Cargo.toml | 3 + codex-rs/direct-tool-timing/src/lib.rs | 4 + codex-rs/direct-tool-timing/src/recorder.rs | 54 +++- .../src/recorder/runtime.rs | 90 +++++++ .../src/recorder/runtime_tests.rs | 99 +++++++ codex-rs/features/src/lib.rs | 8 + 31 files changed, 1436 insertions(+), 106 deletions(-) create mode 100644 codex-rs/core/src/guardian/decision_timing_tests.rs create mode 100644 codex-rs/core/tests/suite/critical_path_runtime.rs create mode 100644 codex-rs/direct-tool-timing/src/recorder/runtime.rs create mode 100644 codex-rs/direct-tool-timing/src/recorder/runtime_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b9ab8a1d90e7..dfa360b42a13 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2954,6 +2954,7 @@ dependencies = [ "codex-context-fragments", "codex-core-plugins", "codex-diagnostics", + "codex-direct-tool-timing", "codex-exec-server", "codex-exec-server-test-support", "codex-execpolicy", @@ -3003,6 +3004,7 @@ dependencies = [ "codex-utils-audio", "codex-utils-cache", "codex-utils-cargo-bin", + "codex-utils-critical-path", "codex-utils-git-discovery", "codex-utils-head-tail-buffer", "codex-utils-home-dir", @@ -3178,6 +3180,9 @@ version = "0.0.0" dependencies = [ "codex-utils-critical-path", "pretty_assertions", + "test-case", + "tokio", + "tokio-util", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1602f7625ce7..134c3f920b5e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -225,6 +225,7 @@ codex-core-api = { path = "core-api" } codex-core-plugin-common = { path = "core-plugin-common" } codex-core-plugins = { path = "core-plugins" } codex-diagnostics = { path = "diagnostics" } +codex-direct-tool-timing = { path = "direct-tool-timing" } codex-exec = { path = "exec" } codex-file-system = { path = "file-system" } codex-exec-server-protocol = { path = "exec-server-protocol" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9bb640698ed5..542abf90d189 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -34,6 +34,7 @@ codex-context-fragments = { workspace = true } codex-config = { workspace = true } codex-core-plugins = { workspace = true } codex-diagnostics = { workspace = true } +codex-direct-tool-timing = { workspace = true } codex-exec-server = { workspace = true } codex-extension-api = { workspace = true } codex-extension-items = { workspace = true } @@ -78,6 +79,7 @@ codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-audio = { workspace = true } codex-utils-cache = { workspace = true } +codex-utils-critical-path = { workspace = true } codex-utils-git-discovery = { workspace = true } codex-utils-head-tail-buffer = { workspace = true } codex-utils-image = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bbfed66c0dec..0f83fd98aa0a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -978,6 +978,9 @@ "credential_masking": { "type": "boolean" }, + "critical_path_timing": { + "type": "boolean" + }, "current_time_reminder": { "$ref": "#/definitions/FeatureToml_for_CurrentTimeReminderConfigToml" }, @@ -7580,6 +7583,9 @@ "credential_masking": { "type": "boolean" }, + "critical_path_timing": { + "type": "boolean" + }, "current_time_reminder": { "$ref": "#/definitions/FeatureToml_for_CurrentTimeReminderConfigToml" }, diff --git a/codex-rs/core/src/guardian/decision.rs b/codex-rs/core/src/guardian/decision.rs index 79981f57e0c4..3e35b4ad5943 100644 --- a/codex-rs/core/src/guardian/decision.rs +++ b/codex-rs/core/src/guardian/decision.rs @@ -26,13 +26,12 @@ pub(crate) fn spawn_approval_decision( let request: ReviewAction = request.into(); let (tx, rx) = oneshot::channel(); let runtime = session.services.runtime_handle.clone(); + let decision = decide_approval(session, context, review_id, request, reasons, options); let spawn_result = std::thread::Builder::new() .name("codex-approval-review".to_string()) .stack_size(THREAD_STACK_SIZE_BYTES) .spawn(move || { - let decision = runtime.block_on(decide_approval( - session, context, review_id, request, reasons, options, - )); + let decision = runtime.block_on(decision); let _ = tx.send(decision); }); if let Err(err) = spawn_result { @@ -43,80 +42,92 @@ pub(crate) fn spawn_approval_decision( /// `None` requests the existing user flow. No contributor is never an implicit allow. /// Both metric sinks use the originating turn retained by the review context. -pub(crate) async fn decide_approval( +pub(crate) fn decide_approval( session: Arc, context: impl Into, review_id: String, request: impl Into, reasons: ApprovalRequestReasons, mut options: GuardianReviewOptions, -) -> Option { - let context = context.into(); - let request = request.into(); - let (_, history_reset) = session.history_reset().await; - let cancellation = match options.external_cancel.take() { - Some(external) => crate::exec::cancel_when_either(external, history_reset.clone()), - None => history_reset.child_token(), - }; - let _cancel_on_drop = cancellation.clone().drop_guard(); - let turn = context.turn(); - let live_config = session.get_config().await; - let requirements = live_config.config_layer_stack.requirements(); - let model_requires_review = - requirements.auto_review_required_for_model(&context.model_info.slug); - let require_guardian = options.require_guardian - || model_requires_review - || requirements - .approvals_reviewer - .can_set(&codex_protocol::config_types::ApprovalsReviewer::User) - .is_err(); - let full_access = context.environments().has_full_access( - context.approval_policy, - &turn.config.permissions.effective_permission_profile(), - ); - let require_synchronous_review = options.require_synchronous_review; - let retried = reasons.retry.is_some(); - let decision = codex_guardian_reviewer::ReviewRequest { - host: ReviewRuntime { - session: Arc::clone(&session), - history_reset: history_reset.clone(), - context: context.clone(), - request: request.clone(), - reasons, - options, - }, - approval_id: &review_id, - tool_call_id: request.tool_call_id.as_deref(), - action: request.action.as_ref().ok(), - thread_id: session.thread_id, - thread_store: &session.services.thread_extension_data, - category: request.category, - approval_policy: context.approval_policy, - approvals_reviewer: context.approvals_reviewer, - require_guardian, - require_synchronous_review, - model_requires_review, - async_enabled: turn.config.features.enabled(codex_features::Feature::GuardianV2), - retried, - escalated_exec: matches!(&request.request, Ok(GuardianApprovalRequest::ExecCommand { sandbox_permissions, .. }) if sandbox_permissions.requires_escalated_permissions()), - full_access, - cancellation: cancellation.clone(), - timeout: turn.config.guardian_review_timeout, - // Keep the existing turn-level reporting policy during this ownership move. - model: turn.model_info(), - telemetry: &turn.session_telemetry, - log_assessments: turn.config.otel.guardian_assessment_logging_enabled(), - analytics: &session.services.analytics_events_client, - metrics: Some(crate::session::extension_metrics::from_session_telemetry( - turn.session_telemetry.clone(), - )), - } - .decide(&session.services.extensions) - .await; - // Enforce cancellation after extension callbacks too, including cached decisions. - if history_reset.is_cancelled() || cancellation.is_cancelled() { - Some(ReviewDecision::Abort) - } else { - decision +) -> futures::future::BoxFuture<'static, Option> { + let context: GuardianReviewContext = context.into(); + let request: ReviewAction = request.into(); + // Invalidate before scheduling: cancellation may finalize the turn before + // the review future is first polled. Replace this boundary with attribution. + if context + .turn() + .config + .features + .enabled(codex_features::Feature::CriticalPathTiming) + { + context.turn().turn_timing_state.invalidate_critical_path(); } + Box::pin(async move { + let (_, history_reset) = session.history_reset().await; + let cancellation = match options.external_cancel.take() { + Some(external) => crate::exec::cancel_when_either(external, history_reset.clone()), + None => history_reset.child_token(), + }; + let _cancel_on_drop = cancellation.clone().drop_guard(); + let turn = context.turn(); + let live_config = session.get_config().await; + let requirements = live_config.config_layer_stack.requirements(); + let model_requires_review = + requirements.auto_review_required_for_model(&context.model_info.slug); + let require_guardian = options.require_guardian + || model_requires_review + || requirements + .approvals_reviewer + .can_set(&codex_protocol::config_types::ApprovalsReviewer::User) + .is_err(); + let full_access = context.environments().has_full_access( + context.approval_policy, + &turn.config.permissions.effective_permission_profile(), + ); + let require_synchronous_review = options.require_synchronous_review; + let retried = reasons.retry.is_some(); + let decision = codex_guardian_reviewer::ReviewRequest { + host: ReviewRuntime { + session: Arc::clone(&session), + history_reset: history_reset.clone(), + context: context.clone(), + request: request.clone(), + reasons, + options, + }, + approval_id: &review_id, + tool_call_id: request.tool_call_id.as_deref(), + action: request.action.as_ref().ok(), + thread_id: session.thread_id, + thread_store: &session.services.thread_extension_data, + category: request.category, + approval_policy: context.approval_policy, + approvals_reviewer: context.approvals_reviewer, + require_guardian, + require_synchronous_review, + model_requires_review, + async_enabled: turn.config.features.enabled(codex_features::Feature::GuardianV2), + retried, + escalated_exec: matches!(&request.request, Ok(GuardianApprovalRequest::ExecCommand { sandbox_permissions, .. }) if sandbox_permissions.requires_escalated_permissions()), + full_access, + cancellation: cancellation.clone(), + timeout: turn.config.guardian_review_timeout, + // Keep the existing turn-level reporting policy during this ownership move. + model: turn.model_info(), + telemetry: &turn.session_telemetry, + log_assessments: turn.config.otel.guardian_assessment_logging_enabled(), + analytics: &session.services.analytics_events_client, + metrics: Some(crate::session::extension_metrics::from_session_telemetry( + turn.session_telemetry.clone(), + )), + } + .decide(&session.services.extensions) + .await; + // Enforce cancellation after extension callbacks too, including cached decisions. + if history_reset.is_cancelled() || cancellation.is_cancelled() { + Some(ReviewDecision::Abort) + } else { + decision + } + }) } diff --git a/codex-rs/core/src/guardian/decision_timing_tests.rs b/codex-rs/core/src/guardian/decision_timing_tests.rs new file mode 100644 index 000000000000..712b595d8bed --- /dev/null +++ b/codex-rs/core/src/guardian/decision_timing_tests.rs @@ -0,0 +1,63 @@ +#![cfg(test)] +//! Keeps uninstrumented review work out of complete critical-path captures. + +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::time::Instant; + +#[test_case::test_case(true; "capture_enabled")] +#[test_case::test_case(false; "capture_disabled")] +#[tokio::test] +async fn uncaptured_review_invalidates_before_polling(critical_path_timing: bool) { + let (session, mut turn) = crate::session::tests::make_session_and_context().await; + Arc::make_mut(&mut turn.config) + .features + .set_enabled( + codex_features::Feature::CriticalPathTiming, + critical_path_timing, + ) + .unwrap(); + let turn = Arc::new(turn); + turn.turn_timing_state.enable_critical_path(); + turn.turn_timing_state + .mark_turn_started(Instant::now()) + .await; + assert!( + turn.turn_timing_state + .snapshot_path(Instant::now()) + .is_some() + ); + let cancellation = tokio_util::sync::CancellationToken::new(); + cancellation.cancel(); + let decision = crate::guardian::decide_approval( + Arc::new(session), + &turn, + "review".to_string(), + crate::guardian::GuardianApprovalRequest::ApplyPatch { + id: "call".to_string(), + environment_id: "local".to_string(), + cwd: codex_utils_path_uri::PathUri::parse("file:///tmp").unwrap(), + files: Vec::new(), + patch: String::new(), + }, + crate::tools::sandboxing::ApprovalRequestReasons::default(), + crate::guardian::GuardianReviewOptions { + require_guardian: true, + plugin_attribution_override: None, + approval_request_source: codex_analytics::GuardianApprovalRequestSource::MainTurn, + external_cancel: Some(cancellation), + require_synchronous_review: false, + }, + ); + // No polling or worker scheduling is needed to protect a finalizing turn. + assert_eq!( + turn.turn_timing_state + .snapshot_path(Instant::now()) + .is_none(), + critical_path_timing + ); + assert_eq!( + decision.await, + Some(codex_protocol::protocol::ReviewDecision::Abort) + ); +} diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 7602d6721e52..652a43d9582f 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -224,3 +224,7 @@ use review_session::build_guardian_review_session_config as build_guardian_revie #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "decision_timing_tests.rs"] +mod decision_timing_tests; diff --git a/codex-rs/core/src/session/config_refresh.rs b/codex-rs/core/src/session/config_refresh.rs index 08a99f81dc21..016eae3978dd 100644 --- a/codex-rs/core/src/session/config_refresh.rs +++ b/codex-rs/core/src/session/config_refresh.rs @@ -1,4 +1,4 @@ -//! Publishes resolved MCP configuration while preserving session-static settings. +//! Publishes resolved MCP configuration and host timing rollouts for future turns. use std::sync::Arc; @@ -107,6 +107,16 @@ impl Session { } } } + // Publish host rollout changes only to future turn snapshots. Active turns + // must retain one capture policy across tool dispatch and Guardian review. + if matches!(scope, RuntimeConfigRefresh::User) + && let Err(err) = config.features.set_enabled( + Feature::CriticalPathTiming, + next_config.features.enabled(Feature::CriticalPathTiming), + ) + { + warn!("failed to refresh critical path timing config: {err}"); + } let mut config = Arc::new(config); let notify_contributors = !matches!(scope, RuntimeConfigRefresh::Mcp) && !self.services.extensions.config_contributors().is_empty(); diff --git a/codex-rs/core/src/session/config_refresh_tests.rs b/codex-rs/core/src/session/config_refresh_tests.rs index 927a2863095f..b58455836456 100644 --- a/codex-rs/core/src/session/config_refresh_tests.rs +++ b/codex-rs/core/src/session/config_refresh_tests.rs @@ -202,3 +202,94 @@ async fn hook_refresh_retries_current_plugin_policy_after_owner_replacement() -> assert!(session.hooks().preview_session_start(&request).is_empty()); Ok(()) } + +#[test_case::test_case(RuntimeConfigRefresh::User, false; "host enables")] +#[test_case::test_case(RuntimeConfigRefresh::User, true; "host disables")] +#[test_case::test_case(RuntimeConfigRefresh::Mcp, false; "MCP retains disabled")] +#[test_case::test_case(RuntimeConfigRefresh::Mcp, true; "MCP retains enabled")] +#[test_case::test_case(RuntimeConfigRefresh::UserFiles, false; "file retains disabled")] +#[test_case::test_case(RuntimeConfigRefresh::UserFiles, true; "file retains enabled")] +#[tokio::test] +async fn critical_path_refresh_changes_only_future_turns( + scope: RuntimeConfigRefresh, + initially_enabled: bool, +) { + let (session, _) = make_session_and_context().await; + let mut initial = session.get_config().await.as_ref().clone(); + initial + .features + .set_enabled(Feature::CriticalPathTiming, initially_enabled) + .unwrap(); + let original = Arc::new(initial); + session + .state + .lock() + .await + .session_configuration + .original_config_do_not_use = Arc::clone(&original); + let active_turn = session.new_default_turn().await; + let mut next_config = original.as_ref().clone(); + next_config + .features + .set_enabled(Feature::CriticalPathTiming, !initially_enabled) + .unwrap(); + + assert_eq!( + session.refresh_config(original, next_config, scope).await, + crate::ConfigRefreshOutcome::Published + ); + + let next_turn = session.new_default_turn().await; + assert_eq!( + active_turn + .config + .features + .enabled(Feature::CriticalPathTiming), + initially_enabled + ); + assert_eq!( + next_turn + .config + .features + .enabled(Feature::CriticalPathTiming), + initially_enabled != matches!(scope, RuntimeConfigRefresh::User) + ); +} + +#[tokio::test] +async fn critical_path_refresh_respects_managed_disable() { + let (session, _) = make_session_and_context().await; + let mut initial = session.get_config().await.as_ref().clone(); + initial.features = crate::config::ManagedFeatures::from_configured( + initial.features.get().clone(), + Some(Sourced::new( + serde_json::from_value(json!({"critical_path_timing": false})).unwrap(), + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), + ) + .unwrap(); + let original = Arc::new(initial); + session + .state + .lock() + .await + .session_configuration + .original_config_do_not_use = Arc::clone(&original); + let mut next_config = original.as_ref().clone(); + let mut incoming_features = original.features.get().clone(); + incoming_features.enable(Feature::CriticalPathTiming); + next_config.features = incoming_features.into(); + + assert_eq!( + session.refresh_runtime_config(original, next_config).await, + crate::ConfigRefreshOutcome::Published + ); + assert!( + !session + .new_default_turn() + .await + .config + .features + .enabled(Feature::CriticalPathTiming) + ); +} diff --git a/codex-rs/core/src/session/elicitation_holders_tests.rs b/codex-rs/core/src/session/elicitation_holders_tests.rs index c1d8ab53f09b..2d86b74c623d 100644 --- a/codex-rs/core/src/session/elicitation_holders_tests.rs +++ b/codex-rs/core/src/session/elicitation_holders_tests.rs @@ -1,5 +1,7 @@ +use codex_features::Feature; use std::collections::HashMap; use std::sync::Arc; +use std::time::Instant; use codex_core_plugins::PluginCommandAttribution; use codex_plugin::PluginId; @@ -18,6 +20,27 @@ use super::tests::make_session_and_context_with_rx; use crate::session::step_context::StepContext; use crate::state::ActiveTurn; +async fn start_timing(turn: &mut Arc, enabled: bool) { + Arc::make_mut(&mut Arc::get_mut(turn).expect("unique test turn").config) + .features + .set_enabled(Feature::CriticalPathTiming, enabled) + .unwrap(); + // Populate state independently to verify disabled capture does not invalidate it. + turn.turn_timing_state.enable_critical_path(); + turn.turn_timing_state + .mark_turn_started(Instant::now()) + .await; +} + +fn assert_uncaptured_wait(turn: &super::turn_context::TurnContext, enabled: bool) { + assert_eq!( + turn.turn_timing_state + .snapshot_path(Instant::now()) + .is_none(), + enabled + ); +} + async fn wait_until_held(pause_state: &mut watch::Receiver) { pause_state .wait_for(|paused| *paused) @@ -32,9 +55,12 @@ async fn wait_until_released(pause_state: &mut watch::Receiver) { .expect("elicitation service should remain available"); } +#[test_case::test_case(true; "capture_on")] +#[test_case::test_case(false; "capture_off")] #[tokio::test] -async fn command_approval_holds_an_elicitation_until_response() { - let (session, turn_context, events) = make_session_and_context_with_rx().await; +async fn command_approval_holds_an_elicitation_until_response(capture: bool) { + let (session, mut turn_context, events) = make_session_and_context_with_rx().await; + start_timing(&mut turn_context, capture).await; *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); #[allow(deprecated)] @@ -82,11 +108,13 @@ async fn command_approval_holds_an_elicitation_until_response() { .await; request.await.expect("approval task"); wait_until_released(&mut pause_state).await; + assert_uncaptured_wait(&turn_context, capture); } #[tokio::test] async fn patch_approval_holds_an_elicitation_until_response() { - let (session, turn_context, events) = make_session_and_context_with_rx().await; + let (session, mut turn_context, events) = make_session_and_context_with_rx().await; + start_timing(&mut turn_context, /*enabled*/ true).await; *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); @@ -113,11 +141,13 @@ async fn patch_approval_holds_an_elicitation_until_response() { .await; request.await.expect("approval task"); wait_until_released(&mut pause_state).await; + assert_uncaptured_wait(&turn_context, /*enabled*/ true); } #[tokio::test] async fn permission_request_holds_an_elicitation_until_response() { - let (session, turn_context, events) = make_session_and_context_with_rx().await; + let (session, mut turn_context, events) = make_session_and_context_with_rx().await; + start_timing(&mut turn_context, /*enabled*/ true).await; *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); @@ -160,11 +190,16 @@ async fn permission_request_holds_an_elicitation_until_response() { .await; request.await.expect("permission request task"); wait_until_released(&mut pause_state).await; + assert_uncaptured_wait(&turn_context, /*enabled*/ true); } +#[test_case::test_case(true, false; "blocking")] +#[test_case::test_case(false, false; "nonblocking_ui_label")] +#[test_case::test_case(true, true; "cancelled")] #[tokio::test] -async fn request_user_input_holds_an_elicitation_until_response() { - let (session, turn_context, events) = make_session_and_context_with_rx().await; +async fn request_user_input_holds_an_elicitation_until_response(blocking: bool, cancel: bool) { + let (session, mut turn_context, events) = make_session_and_context_with_rx().await; + start_timing(&mut turn_context, /*enabled*/ true).await; *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); @@ -178,7 +213,7 @@ async fn request_user_input_holds_an_elicitation_until_response() { "call-1".to_string(), RequestUserInputArgs { questions: Vec::new(), - is_blocking: true, + is_blocking: blocking, auto_resolution_ms: None, }, ) @@ -189,6 +224,13 @@ async fn request_user_input_holds_an_elicitation_until_response() { events.recv().await.expect("request user input event"); wait_until_held(&mut pause_state).await; + if cancel { + request.abort(); + assert!(matches!(request.await, Err(error) if error.is_cancelled())); + wait_until_released(&mut pause_state).await; + assert_uncaptured_wait(&turn_context, /*enabled*/ true); + return; + } let response = RequestUserInputResponse { answers: HashMap::new(), }; @@ -198,4 +240,5 @@ async fn request_user_input_holds_an_elicitation_until_response() { request.await.expect("request user input task"); wait_until_released(&mut pause_state).await; + assert_uncaptured_wait(&turn_context, /*enabled*/ true); } diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index b312cc7345a1..895770147c12 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -732,6 +732,15 @@ impl Session { }; } + // This callback carries a request ID, not a reliable tool invocation owner. + // Do not report its human wait as ordinary tool execution. + if turn_context + .config + .features + .enabled(Feature::CriticalPathTiming) + { + turn_context.turn_timing_state.invalidate_critical_path(); + } let _elicitation = self.services.elicitations.register(); let (tx_response, rx_response) = oneshot::channel(); let prev_entry = { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b6dccabe0257..1e70c9a7e458 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2998,6 +2998,17 @@ impl Session { .await; } + fn invalidate_uncaptured_user_wait(&self, turn_context: &TurnContext) { + // Replace this boundary with owner-scoped timing when user waits are connected. + if turn_context + .config + .features + .enabled(Feature::CriticalPathTiming) + { + turn_context.turn_timing_state.invalidate_critical_path(); + } + } + /// Emit an exec approval request event and await the user's decision. /// /// The request is keyed by `call_id` + `approval_id` so matching responses @@ -3100,6 +3111,7 @@ impl Session { available_decisions: Some(available_decisions), parsed_cmd, }); + self.invalidate_uncaptured_user_wait(turn_context); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or(ReviewDecision::Abort) } @@ -3142,6 +3154,7 @@ impl Session { reason, grant_root, }); + self.invalidate_uncaptured_user_wait(turn_context); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or(ReviewDecision::Abort) } @@ -3307,6 +3320,7 @@ impl Session { permissions: requested_permissions, cwd: Some(cwd.clone().into()), }); + self.invalidate_uncaptured_user_wait(turn_context); self.send_event(turn_context.as_ref(), event).await; tokio::select! { biased; @@ -3360,6 +3374,7 @@ impl Session { turn_context .turn_metadata_state .mark_user_input_requested_during_turn(); + self.invalidate_uncaptured_user_wait(turn_context); self.send_event(turn_context, event).await; rx_response.await.ok() } @@ -4189,6 +4204,14 @@ impl Session { communication: impl Into, ) { let communication = communication.into(); + // Agent-message dependencies are not captured by direct-tool timing. + if turn_context + .config + .features + .enabled(Feature::CriticalPathTiming) + { + turn_context.turn_timing_state.invalidate_critical_path(); + } let mut response_item = communication.to_model_input_item(); if let ResponseItem::AgentMessage { content, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 241ead83e87b..e85587b72e66 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -222,6 +222,7 @@ use opentelemetry_sdk::metrics::data::MetricData; use opentelemetry_sdk::metrics::data::ResourceMetrics; use std::path::Path; use std::time::Duration; +use std::time::Instant; use test_case::test_case; use tokio::sync::Notify; use tokio::sync::Semaphore; @@ -715,15 +716,35 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled( assert!(rx.try_recv().is_err()); } -#[test_case(SessionSource::Exec; "root")] -#[test_case(SessionSource::SubAgent(SubAgentSource::Review); "subagent")] -#[test_case(SessionSource::Internal(InternalSessionSource::Guardian); "guardian")] +#[test_case(SessionSource::Exec, true; "root")] +#[test_case(SessionSource::Exec, false; "capture_off")] +#[test_case(SessionSource::SubAgent(SubAgentSource::Review), true; "subagent")] +#[test_case(SessionSource::Internal(InternalSessionSource::Guardian), true; "guardian")] #[tokio::test] -async fn request_mcp_server_elicitation_waits_for_user_response(source: SessionSource) { +async fn request_mcp_server_elicitation_waits_for_user_response( + source: SessionSource, + capture: bool, +) { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; Arc::get_mut(&mut turn_context) .expect("turn context should not be shared") .session_source = source; + Arc::make_mut(&mut Arc::get_mut(&mut turn_context).unwrap().config) + .features + .set_enabled(Feature::CriticalPathTiming, capture) + .unwrap(); + // Enable state independently to prove a disabled feature does not invalidate it. + turn_context.turn_timing_state.enable_critical_path(); + turn_context + .turn_timing_state + .mark_turn_started(Instant::now()) + .await; + assert!( + turn_context + .turn_timing_state + .snapshot_path(Instant::now()) + .is_some() + ); *session.active_turn.lock().await = Some(ActiveTurn::default()); let paused = session.subscribe_elicitation_pause_state(); let request = ElicitationRequest::Url { @@ -764,6 +785,13 @@ async fn request_mcp_server_elicitation_waits_for_user_response(source: SessionS } ); assert!(*paused.borrow()); + assert_eq!( + turn_context + .turn_timing_state + .snapshot_path(Instant::now()) + .is_none(), + capture + ); assert!(!pending.is_finished()); let response = ElicitationResponse { @@ -2358,6 +2386,10 @@ async fn refresh_config_rejects_stale_owner_without_effects(scope: RuntimeConfig !stale_owner.features.enabled(Feature::Mcp20260728), ) .expect("set MCP protocol rollout"); + stale_config + .features + .enable(Feature::CriticalPathTiming) + .unwrap(); assert_eq!( session .refresh_runtime_config(Arc::clone(&stale_owner), stale_owner.as_ref().clone()) @@ -2782,7 +2814,22 @@ async fn inter_agent_communication_waits_for_confirmed_delivery_persistence() { #[tokio::test] async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { - let (mut session, turn_context) = make_session_and_context().await; + let (mut session, mut turn_context) = make_session_and_context().await; + Arc::make_mut(&mut turn_context.config) + .features + .enable(Feature::CriticalPathTiming) + .unwrap(); + turn_context.turn_timing_state.enable_critical_path(); + turn_context + .turn_timing_state + .mark_turn_started(std::time::Instant::now()) + .await; + assert!( + turn_context + .turn_timing_state + .snapshot_path(std::time::Instant::now()) + .is_some() + ); let rollout_path = attach_thread_persistence(&mut session).await; let communication = InterAgentCommunication::new( AgentPath::root().join("worker").expect("worker path"), @@ -2803,6 +2850,12 @@ async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { .record_inter_agent_communication(&turn_context, turn_context.model_info(), communication) .await; + assert!( + turn_context + .turn_timing_state + .snapshot_path(std::time::Instant::now()) + .is_none() + ); let recorded_history = session.clone_history().await; let recorded_items = raw_history_items(&recorded_history); let create_time = recorded_items[0] diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 6f2e62236701..ed445e0dae2c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -3429,7 +3429,9 @@ async fn try_run_sampling_request( .await; if !in_flight.is_empty() { - let _tool_blocking_timing_guard = turn_context.turn_timing_state.begin_tool_blocking(); + let _tool_blocking_timing_guard = turn_context + .turn_timing_state + .begin_tool_blocking(tool_runtime.batch_timing()); let _tool_blocking_span = trace_span!( "codex.tool_blocking", codex.turn.phase = "tool_blocking", diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 2ebfcc173d97..2d2f7e66e37c 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -997,10 +997,16 @@ impl Session { }, _ = tokio::time::sleep(Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS)) => { warn!("task {sub_id} didn't complete gracefully after {}ms", GRACEFULL_INTERRUPTION_TIMEOUT_MS); + // Forced teardown can abort independently spawned tool dispatches + // whose guards are destroyed later. Do not publish complete totals + // for a dependency graph that has not settled. + task.turn_context.turn_timing_state.invalidate_critical_path(); } } task.handle.abort(); + // Aborting schedules destruction; join before freezing the turn's timing. + let _ = task.handle.await; session_task .abort(Arc::clone(self), Arc::clone(&task.turn_context)) diff --git a/codex-rs/core/src/tasks/mod_tests.rs b/codex-rs/core/src/tasks/mod_tests.rs index beeda10229c0..ba03d83a15d4 100644 --- a/codex-rs/core/src/tasks/mod_tests.rs +++ b/codex-rs/core/src/tasks/mod_tests.rs @@ -494,3 +494,73 @@ fn emit_compact_metric_records_auto_local() { ]) ); } + +struct UncooperativeTimedTask { + entered: Arc, +} + +impl SessionTask for UncooperativeTimedTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + fn span_name(&self) -> &'static str { + "session_task.uncooperative_timing" + } + async fn run( + self: Arc, + _session: Arc, + context: Arc, + _input: Vec, + _cancellation_token: CancellationToken, + ) -> SessionTaskResult { + let timing = &context.turn_timing_state; + timing.enable_critical_path(); + let batch = codex_direct_tool_timing::ToolBatchTiming::default(); + let now = std::time::Instant::now(); + let _blocking; + let _work = timing.direct_tool_timings.start_turn_direct_call( + "uncooperative", + &context.sub_id, + batch.clone(), + now, + timing.snapshot_path(now), + ); + _blocking = timing.begin_tool_blocking(Some(batch)); + let _review = _work.start_review(); + self.entered.notify_one(); + std::future::pending().await + } +} + +#[tokio::test(flavor = "current_thread")] +async fn hard_abort_drops_timing_guards_before_profile_completion() { + let (session, context, _receiver) = make_session_and_context_with_rx().await; + let entered = Arc::new(tokio::sync::Notify::new()); + session + .spawn_task( + Arc::clone(&context), + Vec::new(), + UncooperativeTimedTask { + entered: Arc::clone(&entered), + }, + ) + .await; + entered.notified().await; + session + .abort_all_tasks(codex_protocol::protocol::TurnAbortReason::Interrupted) + .await; + // The task's review and tool guards are gone before abort completion. + assert!( + context + .turn_timing_state + .direct_tool_timings + .owner_for_turn("uncooperative", &context.sub_id) + .is_none() + ); + assert_eq!( + context + .turn_timing_state + .snapshot_path(std::time::Instant::now()), + None + ); +} diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 3472d9595364..6c6f3d10da64 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -44,6 +44,14 @@ impl SessionTask for RegularTask { input: Vec, cancellation_token: CancellationToken, ) -> SessionTaskResult { + if ctx + .config + .features + .enabled(codex_features::Feature::CriticalPathTiming) + && !ctx.session_source.is_non_root_agent() + { + ctx.turn_timing_state.enable_critical_path(); + } let run_turn_span = trace_span!( "run_turn", conversation.id = %sess.thread_id, diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 359ca8a0eac6..b9f94b7eb18f 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -96,6 +96,38 @@ pub fn mcp_attribution_snapshot( .mcp_attribution_snapshot() } +/// Retains the active turn's timing after completion without exporting telemetry. +/// Returns `None` when there is no running task. The observer reports exclusive +/// durations for the caller's interval in inference, automatic review, local +/// tools, overhead, and unknown order. +pub async fn observe_turn_timing( + thread: &crate::CodexThread, +) -> Option< + impl Fn(std::time::Instant, std::time::Instant) -> Option<[std::time::Duration; 5]> + use<>, +> { + let active = thread.session.active_turn.lock().await; + let timing = Arc::clone( + &active + .as_ref()? + .task + .as_ref()? + .turn_context + .turn_timing_state, + ); + Some(move |start, end| { + timing.snapshot_path(end).map(|path| { + let totals = path.durations(start, end); + [ + totals.inference, + totals.auto_review, + totals.tools, + totals.overhead, + totals.unknown, + ] + }) + }) +} + /// Test-only provider that supplies no user instructions. #[derive(Debug, Default)] pub struct EmptyUserInstructionsProvider; diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 4dea6384f39f..7d11aff46394 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -27,6 +27,9 @@ use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; +use codex_direct_tool_timing::ToolAdmission; +use codex_direct_tool_timing::ToolBatchTiming; +use codex_direct_tool_timing::ToolGateTiming; use codex_history::ResponseItemEnvelope; use codex_protocol::error::CodexErr; use codex_protocol::models::ResponseInputItem; @@ -49,6 +52,25 @@ pub(crate) struct ToolCallRuntime { step_context: Arc, tracker: SharedTurnDiffTracker, parallel_execution: Arc>, + timing: Option, +} + +#[derive(Clone, Default)] +struct DirectToolTiming { + gate: ToolGateTiming, + batch: ToolBatchTiming, +} + +// Remove this boundary when CodeMode and subagent dependencies have runtime capture. +fn direct_path_supported(tool_name: &codex_tools::ToolName, source: &ToolCallSource) -> bool { + !(matches!(source, ToolCallSource::CodeMode { .. }) + || (tool_name.is_default_namespace() + && matches!(tool_name.name.as_str(), + crate::tools::code_mode::PUBLIC_TOOL_NAME | crate::tools::code_mode::WAIT_TOOL_NAME)) + // Multi-agent tool namespaces can be overridden by the tool catalog. + || matches!(tool_name.name.as_str(), "spawn_agent" | "send_input" + | "close_agent" | "resume_agent" | "wait_agent" | "list_agents" + | "followup_task" | "interrupt_agent" | "send_message")) } impl ToolCallRuntime { @@ -57,11 +79,18 @@ impl ToolCallRuntime { step_context: Arc, tracker: SharedTurnDiffTracker, ) -> Self { + let timing = step_context + .turn + .config + .features + .enabled(codex_features::Feature::CriticalPathTiming) + .then(DirectToolTiming::default); Self { session, step_context, tracker, parallel_execution: Arc::new(RwLock::new(())), + timing, } } @@ -152,6 +181,35 @@ impl ToolCallRuntime { let lock = Arc::clone(&self.parallel_execution); let invocation_cancellation_token = cancellation_token.clone(); let started = Instant::now(); + if turn + .config + .features + .enabled(codex_features::Feature::CriticalPathTiming) + && !direct_path_supported( + &tool_runtime + .as_ref() + .map_or_else(|| call.tool_name.clone(), |runtime| runtime.tool_name()), + &source, + ) + { + turn.turn_timing_state.invalidate_critical_path(); + } + let work_timing = self.timing.and_then(|timing| match &source { + ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => { + let work = turn + .turn_timing_state + .direct_tool_timings + .start_turn_direct_call( + &call.call_id, + &turn.sub_id, + timing.batch, + started, + turn.turn_timing_state.snapshot_path(started), + ); + Some((timing.gate, work)) + } + ToolCallSource::CodeMode { .. } => None, + }); let mut tool_call_timing_guard = ToolCallTimingGuard::capture( started, &session.thread_id, @@ -208,7 +266,7 @@ impl ToolCallRuntime { async move { let _message_admission = message_admission?; // Readiness and admission remain cancellable even when the handler must finish. - let guard = async { + let (guard, admission_started) = async { if let Some(tool_runtime) = tool_runtime && let Some(readiness) = tool_runtime.wait_until_ready(&session) { @@ -217,7 +275,8 @@ impl ToolCallRuntime { .await; } - if supports_parallel { + let admission_started = Instant::now(); + let guard = if supports_parallel { Either::Left( lock.read() .instrument(trace_span!("tool_dispatch.parallel_lock")) @@ -229,10 +288,24 @@ impl ToolCallRuntime { .instrument(trace_span!("tool_dispatch.exclusive_lock")) .await, ) - } + }; + (guard, admission_started) } .or_cancel_tool(&invocation_cancellation_token) .await?; + // Publish the completed path before releasing the admission lock, + // including when cancellation drops the handler future. + let _admitted_timing = work_timing.map(|(gate, work)| { + gate.admit( + work, + if supports_parallel { + ToolAdmission::Parallel + } else { + ToolAdmission::Exclusive + }, + admission_started, + ) + }); // Admission through the parallel-execution gate marks the end // of dispatch waiting and the start of handler execution. if let Some(execution_started_at) = execution_started_at { @@ -250,6 +323,7 @@ impl ToolCallRuntime { dispatch_call_state, ) .await; + drop(_admitted_timing); drop(guard); // The sampling loop collects results in order only after its stream ends. // Record readiness here, before either caller encodes or collects the result. @@ -347,6 +421,10 @@ impl ToolCallRuntime { } impl ToolCallRuntime { + pub(crate) fn batch_timing(&self) -> Option { + self.timing.as_ref().map(|timing| timing.batch.clone()) + } + fn tool_task_join_error(err: JoinError) -> FunctionCallError { FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}")) } @@ -529,6 +607,40 @@ mod tests { use tokio::sync::oneshot; use tracing_test::internal::MockWriter; + #[test] + fn unsupported_handlers_remain_incomplete_after_namespace_resolution() { + let handler = crate::tools::multi_agent_tool::multi_agent_v2_handler( + crate::tools::handlers::multi_agents_v2::WaitAgentHandler::default(), + Some("custom_collaboration"), + /*description_override*/ None, + /*parameters_override*/ None, + ); + let registry = ToolRegistry::from_tools([handler]); + let registered = registry + .registered_tool(&codex_tools::ToolName::namespaced( + "custom_collaboration", + "wait_agent", + )) + .unwrap(); + assert!(!direct_path_supported( + ®istered.runtime.tool_name(), + &ToolCallSource::Direct + )); + for name in [ + crate::tools::code_mode::PUBLIC_TOOL_NAME, + crate::tools::code_mode::WAIT_TOOL_NAME, + ] { + assert!(!direct_path_supported( + &codex_tools::ToolName::plain(name).with_default_namespace(), + &ToolCallSource::Direct + )); + } + assert!(direct_path_supported( + &codex_tools::ToolName::namespaced("mcp", "exec"), + &ToolCallSource::Direct + )); + } + #[test] fn tool_call_timing_guard_ignores_code_mode_source() { let buffer: &'static std::sync::Mutex> = @@ -622,13 +734,34 @@ mod tests { }); } - #[test_case::test_case(false; "ordinary_handler")] - #[test_case::test_case(true; "dynamic_handler")] + #[test_case::test_case(false, false; "ordinary_capture_disabled")] + #[test_case::test_case(false, true; "ordinary_capture_enabled")] + #[test_case::test_case(true, false; "dynamic_capture_disabled")] + #[test_case::test_case(true, true; "dynamic_capture_enabled")] #[tokio::test] async fn cancellation_before_dispatch_admission_logs_dispatch_only_timing( dynamic: bool, + critical_path_timing: bool, ) -> anyhow::Result<()> { - let (session, turn_context) = crate::session::tests::make_session_and_context().await; + let (session, mut turn_context) = crate::session::tests::make_session_and_context().await; + Arc::make_mut(&mut turn_context.config) + .features + .enable(codex_features::Feature::RuntimeMetrics) + .unwrap(); + Arc::make_mut(&mut turn_context.config) + .features + .set_enabled( + codex_features::Feature::CriticalPathTiming, + critical_path_timing, + ) + .unwrap(); + if critical_path_timing { + turn_context.turn_timing_state.enable_critical_path(); + } + turn_context + .turn_timing_state + .mark_turn_started(Instant::now()) + .await; let session = Arc::new(session); let turn_context = Arc::new(turn_context); let tool_name = codex_tools::ToolName::plain("test_tool"); @@ -661,6 +794,8 @@ mod tests { let step_context = step_context.with_tool_router_for_test(router); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); let runtime = ToolCallRuntime::new(session, step_context, tracker); + let batch = runtime.batch_timing(); + assert_eq!(batch.is_some(), critical_path_timing); let execution_gate = Arc::clone(&runtime.parallel_execution); let execution_gate_guard = execution_gate .try_write_owned() @@ -700,6 +835,11 @@ mod tests { .expect("cancelled tool response task should join") .expect("cancelled tool call should produce a response"); + assert_eq!( + batch.and_then(|batch| batch.completed()).is_some(), + critical_path_timing, + "capture requires its own gate and must finish before cancellation returns" + ); let logs = String::from_utf8( buffer .lock() diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 37d238b7ec79..84d1d05ef73e 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -7,6 +7,7 @@ use std::time::SystemTime; use std::time::UNIX_EPOCH; use codex_analytics::TurnProfile; +use codex_direct_tool_timing::DirectToolTimings; use codex_otel::TURN_TTFM_DURATION_METRIC; use codex_protocol::ThreadId; use codex_protocol::items::TurnItem; @@ -16,6 +17,9 @@ use tokio::sync::Mutex; use crate::ResponseEvent; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::raw_assistant_output_text_from_item; +use codex_direct_tool_timing::ToolBatchTiming; +use codex_utils_critical_path::CriticalPath; +use codex_utils_critical_path::WorkKind; // Reuse the finalized analytics counters instead of counting sampled child spans. // Recovery can complete the same turn ID again, so consumers must retain the @@ -59,6 +63,7 @@ pub(crate) async fn record_turn_ttfm_metric(turn_context: &TurnContext, item: &T #[derive(Debug, Default)] pub(crate) struct TurnTimingState { + pub(crate) direct_tool_timings: DirectToolTimings, state: Mutex, profile: StdMutex, } @@ -74,6 +79,8 @@ struct TurnTimingStateInner { #[derive(Debug, Default)] struct TurnProfileState { + path: Option, + tool_dependency: Option, started_at: Option, last_transition_at: Option, active_phase: Option, @@ -102,9 +109,33 @@ pub(crate) struct TurnProfileTimingGuard { timing: Arc, phase: TurnProfilePhase, active: bool, + tool_batch: Option, } impl TurnTimingState { + pub(crate) fn enable_critical_path(&self) { + let mut state = self.profile_state(); + if state.completed_profile.is_none() { + state.path = Some(CriticalPath::default()); + } + } + + pub(crate) fn invalidate_critical_path(&self) { + let mut state = self.profile_state(); + if state.completed_profile.is_none() { + state.path = None; + } + } + + pub(crate) fn snapshot_path(&self, now: Instant) -> Option { + let state = self.profile_state(); + if state.completed_profile.is_some() { + state.path.clone() + } else { + state.path_at(now) + } + } + pub(crate) async fn mark_turn_started(&self, started_at: Instant) -> i64 { let started_at_unix_ms = now_unix_timestamp_ms(); let mut state = self.state.lock().await; @@ -166,6 +197,7 @@ impl TurnTimingState { timing: Arc::clone(self), phase: TurnProfilePhase::Sampling, active, + tool_batch: None, } } @@ -186,15 +218,20 @@ impl TurnTimingState { timing: Arc::clone(self), phase: TurnProfilePhase::Compaction, active, + tool_batch: None, } } - pub(crate) fn begin_tool_blocking(self: &Arc) -> TurnProfileTimingGuard { + pub(crate) fn begin_tool_blocking( + self: &Arc, + batch: Option, + ) -> TurnProfileTimingGuard { let active = self.profile_state().begin_tool_blocking(Instant::now()); TurnProfileTimingGuard { timing: Arc::clone(self), phase: TurnProfilePhase::ToolBlocking, active, + tool_batch: batch, } } @@ -227,9 +264,14 @@ impl TurnTimingState { impl Drop for TurnProfileTimingGuard { fn drop(&mut self) { if self.active { - self.timing - .profile_state() - .end_phase(Instant::now(), self.phase); + let mut state = self.timing.profile_state(); + if state.completed_profile.is_some() { + return; + } + if let Some(batch) = &self.tool_batch { + state.tool_dependency = batch.completed().and_then(|work| work.path); + } + state.end_phase(Instant::now(), self.phase); } } } @@ -251,7 +293,9 @@ fn duration_to_u64_ms(duration: Duration) -> u64 { impl TurnProfileState { fn start(&mut self, started_at: Instant) { + let path = self.path.clone(); *self = Self { + path, started_at: Some(started_at), last_transition_at: Some(started_at), ..Self::default() @@ -318,6 +362,11 @@ impl TurnProfileState { return; }; let elapsed = now.saturating_duration_since(previous); + self.path = self + .path + .as_ref() + .map(|path| self.extend_path(path.clone(), previous, now)); + self.tool_dependency = None; match self.active_phase { Some(TurnProfilePhase::Sampling) => self.sampling += elapsed, Some(TurnProfilePhase::Compaction) => self.compaction += elapsed, @@ -327,11 +376,33 @@ impl TurnProfileState { } } + fn path_at(&self, now: Instant) -> Option { + let path = self.path.clone()?; + Some(self.extend_path(path, self.last_transition_at?, now)) + } + + fn extend_path(&self, path: CriticalPath, previous: Instant, now: Instant) -> CriticalPath { + match self.active_phase { + // Compaction is model-related elapsed work, including local bookkeeping; + // the existing profile still exposes its separate compaction_ms duration. + Some(TurnProfilePhase::Sampling | TurnProfilePhase::Compaction) => { + path.work(previous, now, WorkKind::Inference) + } + Some(TurnProfilePhase::ToolBlocking) => { + let dependency = self + .tool_dependency + .clone() + .unwrap_or_else(|| path.clone().work(previous, now, WorkKind::Unknown)); + path.join(previous, now, dependency) + } + None => path.work(previous, now, WorkKind::Overhead), + } + } + fn complete(&mut self, now: Instant) -> TurnProfile { if let Some(profile) = self.completed_profile.as_ref() { return profile.clone(); } - let final_phase = self.active_phase; self.advance(now); let after_last_sampling = if self.seen_sampling { diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 7aa7d3d177a9..1045ad16afed 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -1,10 +1,15 @@ +use super::ToolBatchTiming; use codex_analytics::TurnProfile; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; +use codex_utils_critical_path::CriticalPath; +use codex_utils_critical_path::PathDurations; +use codex_utils_critical_path::WorkKind; use pretty_assertions::assert_eq; +use std::sync::Arc; use std::time::Duration; use std::time::Instant; use std::time::SystemTime; @@ -256,7 +261,10 @@ fn turn_profile_breaks_down_sampling_blocking_and_retry_overhead() { #[test] fn turn_profile_counts_compaction_as_an_exclusive_phase() { let started_at = Instant::now(); - let mut state = TurnProfileState::default(); + let mut state = TurnProfileState { + path: Some(CriticalPath::default()), + ..Default::default() + }; state.start(started_at); let _ = state.begin_compaction(started_at + Duration::from_millis(100)); @@ -285,6 +293,14 @@ fn turn_profile_counts_compaction_as_an_exclusive_phase() { tools_change_count: 0, } ); + let totals = state + .path + .as_ref() + .unwrap() + .durations(started_at, started_at + Duration::from_millis(1_100)); + assert_eq!(totals.inference, Duration::from_millis(800)); + assert_eq!(totals.overhead, Duration::from_millis(300)); + assert_eq!(totals.user_wait, Duration::ZERO); } #[tokio::test] @@ -307,3 +323,92 @@ async fn turn_profile_and_duration_share_a_completion_instant() { Some(i64::try_from(classified_ms).expect("profile duration should fit i64")) ); } + +#[test] +fn capture_preserves_phases_and_freezes_missing_dependencies() { + let started_at = Instant::now(); + let time_after_turn_start = |millis| started_at + Duration::from_millis(millis); + let mut state = TurnProfileState::default(); + state.start(started_at); + assert!(state.path_at(time_after_turn_start(10)).is_none()); + state.path = Some(CriticalPath::default()); + assert!(state.begin_sampling(time_after_turn_start(10))); + state.end_phase(time_after_turn_start(20), TurnProfilePhase::Sampling); + assert!(state.begin_tool_blocking(time_after_turn_start(20))); + state.end_phase(time_after_turn_start(50), TurnProfilePhase::ToolBlocking); + let profile = state.complete(time_after_turn_start(60)); + assert_eq!( + state + .path + .as_ref() + .unwrap() + .durations(started_at, time_after_turn_start(60)), + PathDurations { + inference: Duration::from_millis(10), + unknown: Duration::from_millis(30), + overhead: Duration::from_millis(20), + ..PathDurations::default() + } + ); + let frozen_path = state.path.clone(); + state.end_phase(time_after_turn_start(70), TurnProfilePhase::ToolBlocking); + assert_eq!(state.complete(time_after_turn_start(80)), profile); + assert_eq!(state.path, frozen_path); +} + +#[tokio::test] +async fn invalidation_and_late_guard_drop_cannot_restore_a_frozen_path() { + let timing = Arc::new(TurnTimingState::default()); + timing.enable_critical_path(); + timing.mark_turn_started(Instant::now()).await; + let guard = timing.begin_tool_blocking(Some(ToolBatchTiming::default())); + timing.invalidate_critical_path(); + timing.complete_profile_and_duration_ms().await; + timing.enable_critical_path(); + drop(guard); + assert!(timing.snapshot_path(Instant::now()).is_none()); +} + +#[tokio::test] +async fn completed_batch_guard_retains_its_path_across_turns() { + let started_at = Instant::now() - Duration::from_secs(2); + let previous_turn = TurnTimingState::default(); + let previous_batch = ToolBatchTiming::default(); + let previous_call = previous_turn.direct_tool_timings.start_turn_direct_call( + "reviewed", + "turn", + previous_batch.clone(), + started_at, + Some(CriticalPath::default()), + ); + drop(previous_turn); + let timing = Arc::new(TurnTimingState::default()); + timing.enable_critical_path(); + timing.mark_turn_started(started_at).await; + let batch = ToolBatchTiming::default(); + let review_path = CriticalPath::default().work( + started_at, + started_at + Duration::from_secs(1), + WorkKind::AutoReview, + ); + let call = timing.direct_tool_timings.start_turn_direct_call( + "reviewed", + "turn", + batch.clone(), + started_at + Duration::from_secs(1), + Some(review_path), + ); + // A late guard from an earlier turn cannot finish or invalidate the reused ID. + drop(previous_call); + assert!(previous_batch.completed().unwrap().path.is_some()); + assert!(batch.completed().is_none()); + let guard = timing.begin_tool_blocking(Some(batch)); + drop(call); + drop(guard); + timing.complete_profile_and_duration_ms().await; + let path = timing.snapshot_path(Instant::now()).unwrap(); + let totals = path.durations(started_at, path.end().unwrap()); + assert_eq!(totals.auto_review, Duration::from_secs(1)); + assert_eq!(totals.unknown, Duration::ZERO); + assert!(totals.tools >= Duration::from_secs(1)); +} diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 13f0e8d778de..e53d538054d4 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -59,7 +59,10 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -struct ApprovedReviewContributor; +struct ApprovedReviewContributor { + entered: tokio::sync::Notify, + release: tokio::sync::Semaphore, +} impl ApprovalReviewContributor for ApprovedReviewContributor { fn decide<'a>( @@ -67,6 +70,8 @@ impl ApprovalReviewContributor for ApprovedReviewContributor { input: &'a codex_extension_api::ApprovalDecisionInput<'_>, ) -> ExtensionFuture<'a, Option> { Box::pin(async move { + self.entered.notify_one(); + self.release.acquire().await.expect("review gate").forget(); assert!(input.metrics.is_some()); assert_eq!(input.action["tool"], "request_permissions"); Some(codex_extension_api::ApprovalDecision::Allow) @@ -125,11 +130,19 @@ async fn approval_review_contributor_skips_existing_guardian_model_call() -> Res .await; let mut extensions = ExtensionRegistryBuilder::new(); - extensions.approval_review_contributor(Arc::new(ApprovedReviewContributor)); + let reviewer = Arc::new(ApprovedReviewContributor { + entered: tokio::sync::Notify::new(), + release: tokio::sync::Semaphore::new(/* permits */ 0), + }); + extensions.approval_review_contributor(reviewer.clone()); let mut builder = test_codex() .with_extensions(Arc::new(extensions.build())) .with_config(|config| { config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config + .features + .enable(Feature::CriticalPathTiming) + .expect("critical-path capture is configurable"); config .features .enable(Feature::RequestPermissionsTool) @@ -143,6 +156,13 @@ async fn approval_review_contributor_skips_existing_guardian_model_call() -> Res }])) .await?; + reviewer.entered.notified().await; + let observe = codex_core::test_support::observe_turn_timing(&test.codex) + .await + .expect("active turn"); + let now = std::time::Instant::now(); + reviewer.release.add_permits(/* n */ 1); + loop { match wait_for_event(&test.codex, |_| true).await { EventMsg::GuardianAssessment(event) => { @@ -156,6 +176,7 @@ async fn approval_review_contributor_skips_existing_guardian_model_call() -> Res } } + assert_eq!(observe(now, now), None); let requests = responses.requests(); assert_eq!(requests.len(), 2); assert!( @@ -332,7 +353,10 @@ async fn required_model_bypasses_extension_approval_when_guardian_v2_is_disabled let requirements = format!("{reviewer_requirements}[auto_review]\nrequired_on_models = [\"{model}\"]\n"); let mut extensions = ExtensionRegistryBuilder::new(); - extensions.approval_review_contributor(Arc::new(ApprovedReviewContributor)); + extensions.approval_review_contributor(Arc::new(ApprovedReviewContributor { + entered: tokio::sync::Notify::new(), + release: tokio::sync::Semaphore::new(/* permits */ 1), + })); let mut builder = test_codex() .with_model(model) .with_extensions(Arc::new(extensions.build())) diff --git a/codex-rs/core/tests/suite/critical_path_runtime.rs b/codex-rs/core/tests/suite/critical_path_runtime.rs new file mode 100644 index 000000000000..49d3506ea8b2 --- /dev/null +++ b/codex-rs/core/tests/suite/critical_path_runtime.rs @@ -0,0 +1,251 @@ +//! Exercises dependency capture through real tool dispatch and admission gates. +//! Channels hold work open and establish overlap without scheduling sleeps. + +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use codex_core::TurnInputRequest; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ToolContributor; +use codex_features::Feature; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use codex_tools::JsonToolOutput; +use codex_tools::ToolCall; +use codex_tools::ToolExecutor; +use codex_tools::ToolExecutorFuture; +use codex_tools::ToolName; +use codex_tools::ToolOutput; +use codex_tools::ToolSpec; +use core_test_support::responses; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use tokio::sync::Semaphore; +use tokio::sync::mpsc; + +#[derive(Clone, Copy)] +enum Admission { + Serial, + Parallel, + Unsupported, +} + +#[derive(Clone)] +struct GatedTool { + admission: Admission, + entered: mpsc::UnboundedSender<(String, Instant)>, + release: Arc, +} + +impl ToolContributor for GatedTool { + fn tools( + &self, + _session_store: &ExtensionData, + _thread_store: &ExtensionData, + ) -> Vec ToolExecutor>>> { + vec![Arc::new(self.clone())] + } +} + +impl<'call> ToolExecutor> for GatedTool { + fn tool_name(&self) -> ToolName { + ToolName::plain(if matches!(self.admission, Admission::Unsupported) { + "wait_agent" + } else { + "timing_gate" + }) + } + + fn spec(&self) -> ToolSpec { + ToolSpec::Function(codex_tools::ResponsesApiTool { + name: self.tool_name().name, + description: "Wait for the host.".to_string(), + strict: false, + parameters: codex_tools::JsonSchema::default(), + output_schema: None, + defer_loading: None, + }) + } + + fn supports_parallel_tool_calls(&self) -> bool { + matches!(self.admission, Admission::Parallel) + } + + fn handle<'a>(&'a self, call: ToolCall<'call>) -> ToolExecutorFuture<'a> + where + 'call: 'a, + { + Box::pin(async move { + self.entered + .send((call.call_id.to_string(), Instant::now())) + .expect("test receiver"); + self.release.acquire().await.expect("test gate").forget(); + Ok( + Box::new(JsonToolOutput::new(serde_json::json!({"done": true}))) + as Box, + ) + }) + } +} + +#[test_case::test_case(Admission::Serial, false; "default_disabled")] +#[test_case::test_case(Admission::Unsupported, true; "unsupported_enabled")] +#[test_case::test_case(Admission::Serial, true; "serial_enabled")] +#[test_case::test_case(Admission::Parallel, true; "overlapping_enabled")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn critical_path_follows_runtime_admission( + admission: Admission, + critical_path_timing: bool, +) -> anyhow::Result<()> { + let server = responses::start_mock_server().await; + let (entered, mut entries) = mpsc::unbounded_channel(); + let gate = GatedTool { + admission, + entered, + release: Arc::new(Semaphore::new(/* permits */ 0)), + }; + let name = gate.tool_name().name; + let mut extensions = ExtensionRegistryBuilder::new(); + extensions.tool_contributor(Arc::new(gate.clone())); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .with_config(move |config| { + if critical_path_timing { + config + .features + .enable(Feature::CriticalPathTiming) + .expect("critical-path capture is configurable"); + } + }) + .build_with_auto_env(&server) + .await?; + let first = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("tools"), + responses::ev_function_call("a", &name, "{}"), + responses::ev_function_call("b", &name, "{}"), + responses::ev_completed("tools"), + ]), + ) + .await; + let start = Instant::now(); + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Run both tools".to_string(), + text_elements: Vec::new(), + }])) + .await?; + let (first_id, first_start) = entries.recv().await.expect("first admitted tool"); + let observe = codex_core::test_support::observe_turn_timing(&test.codex) + .await + .expect("active turn"); + let reply = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("reply"), + responses::ev_assistant_message("answer", "Done"), + responses::ev_completed("reply"), + ]), + ) + .await; + let (second_start, held_start, held_end) = if matches!(admission, Admission::Parallel) { + let (second_id, second_start) = entries.recv().await.expect("second overlapping tool"); + assert_ne!(first_id, second_id); + // Both handlers are inside their gate before either can finish. + let held_end = Instant::now(); + gate.release.add_permits(/* n */ 2); + (second_start, second_start, held_end) + } else { + assert!( + entries.try_recv().is_err(), + "exclusive handler still owns admission" + ); + let held_end = Instant::now(); + gate.release.add_permits(/* n */ 1); + let (_, second_start) = entries.recv().await.expect("second serial tool"); + gate.release.add_permits(/* n */ 1); + (second_start, first_start, held_end) + }; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + first.single_request(); + let request = reply.single_request(); + for id in ["a", "b"] { + assert!( + request + .function_call_output(id) + .to_string() + .contains("done") + ); + } + let end = Instant::now(); + if matches!(admission, Admission::Unsupported) || !critical_path_timing { + assert_eq!(observe(start, end), None); + } else { + let [inference, review, tools, overhead, unknown] = + observe(start, end).expect("complete direct-tool path"); + assert_eq!( + observe(held_start, held_end), + Some([ + Duration::ZERO, + Duration::ZERO, + held_end.duration_since(held_start), + Duration::ZERO, + Duration::ZERO, + ]) + ); + assert!(inference > Duration::ZERO); + assert!(tools >= second_start.duration_since(first_start)); + assert_eq!((review, unknown), (Duration::ZERO, Duration::ZERO)); + assert_eq!( + inference + review + tools + overhead + unknown, + end.duration_since(start) + ); + // Retaining both branches would count their common prefix twice. + assert!(tools < end.duration_since(start)); + } + Ok(()) +} + +#[tokio::test] +async fn standalone_user_shell_omits_uncaptured_path() -> anyhow::Result<()> { + core_test_support::skip_if_target_windows!(Ok(()), "Unix shell workload"); + let server = responses::start_mock_server().await; + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::CriticalPathTiming) + .expect("critical-path capture is configurable"); + }) + .build_with_auto_env(&server) + .await?; + test.codex + .submit(Op::RunUserShellCommand { + command: "sleep 30".to_string(), + timeout_ms: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ExecCommandBegin(_)) + }) + .await; + let observe = codex_core::test_support::observe_turn_timing(&test.codex) + .await + .expect("active turn"); + test.codex.submit(Op::Interrupt).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnAborted(_)) + }) + .await; + let now = Instant::now(); + assert_eq!(observe(now, now), None); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 9deb12935c48..9bb0c274f278 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -99,6 +99,7 @@ mod guardian_cached_score; mod guardian_checkpoint_migration; // Uses the same command-approval harness as guardian_review below. mod canonical_plugin_connectors; +mod critical_path_runtime; mod gateway_auth; mod guardian_connector_trust; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/core/tests/suite/turn_phase_trace.rs b/codex-rs/core/tests/suite/turn_phase_trace.rs index 0d1d2f18f74a..aa4fac477bb4 100644 --- a/codex-rs/core/tests/suite/turn_phase_trace.rs +++ b/codex-rs/core/tests/suite/turn_phase_trace.rs @@ -63,19 +63,27 @@ enum Finish { Interrupt, } -#[test_case(Finish::Reply; "reply")] -#[test_case(Finish::Interrupt; "interrupt")] +#[test_case(Finish::Reply, false; "reply_disabled")] +#[test_case(Finish::Interrupt, false; "interrupt_disabled")] +#[test_case(Finish::Reply, true; "reply_enabled")] +#[test_case(Finish::Interrupt, true; "interrupt_enabled")] #[tokio::test(flavor = "current_thread")] async fn steer_joins_existing_turn_without_extending_sampling( finish: Finish, + critical_path_timing: bool, ) -> anyhow::Result<()> { if std::env::var_os(SUBPROCESS_ENV_VAR).is_none() { let case = match finish { Finish::Reply => "reply", Finish::Interrupt => "interrupt", }; + let capture = if critical_path_timing { + "enabled" + } else { + "disabled" + }; let test_name = format!( - "suite::turn_phase_trace::steer_joins_existing_turn_without_extending_sampling::{case}" + "suite::turn_phase_trace::steer_joins_existing_turn_without_extending_sampling::{case}_{capture}" ); let output = Command::new(std::env::current_exe()?) .arg("--exact") @@ -117,7 +125,22 @@ async fn steer_joins_existing_turn_without_extending_sampling( ]), ) .await; - let mut test = test_codex().build_with_auto_env(&server).await?; + let mut test = test_codex() + .with_config(move |config| { + config + .features + .enable(codex_features::Feature::RuntimeMetrics) + .expect("runtime metrics feature can be configured"); + config + .features + .set_enabled( + codex_features::Feature::CriticalPathTiming, + critical_path_timing, + ) + .expect("critical path capture can be configured independently"); + }) + .build_with_auto_env(&server) + .await?; let thread = test .thread_manager .start_thread(StartThreadOptions { @@ -152,6 +175,10 @@ async fn steer_joins_existing_turn_without_extending_sampling( unreachable!(); }; first.single_request(); + let timing_start = std::time::Instant::now(); + let observe_timing = codex_core::test_support::observe_turn_timing(&test.codex) + .await + .expect("active turn"); // The tool is still blocked. Sampling must already be exportable, even though // the dispatch future and its trace remain alive until the host responds. let sampling = wait_for_span(&exported, |span| span.name == "codex.sampling").await?; @@ -167,6 +194,7 @@ async fn steer_joins_existing_turn_without_extending_sampling( turn_id: call.turn_id.clone() } ); + let mut interrupted_capture = None; match finish { Finish::Reply => { let reply = mount_sse_once( @@ -204,6 +232,8 @@ async fn steer_joins_existing_turn_without_extending_sampling( matches!(event, EventMsg::TurnAborted(_)) }) .await; + let closed_at = std::time::Instant::now(); + interrupted_capture = Some((closed_at, observe_timing(timing_start, closed_at))); let reply = mount_sse_once( &server, sse(vec![ @@ -253,6 +283,19 @@ async fn steer_joins_existing_turn_without_extending_sampling( ); } } + if let Some((closed_at, captured)) = interrupted_capture { + // Recovery reuses a turn ID, but must not mutate the closed capture. + assert_eq!(observe_timing(timing_start, closed_at), captured); + } + let timing_end = std::time::Instant::now(); + let totals = observe_timing(timing_start, timing_end); + if critical_path_timing { + let [_, review, tools, _, unknown] = totals.expect("captured direct-tool path"); + assert!(tools > Duration::ZERO); + assert_eq!((review, unknown), (Duration::ZERO, Duration::ZERO)); + } else { + assert_eq!(totals, None); + } test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::ShutdownComplete) diff --git a/codex-rs/direct-tool-timing/Cargo.toml b/codex-rs/direct-tool-timing/Cargo.toml index 82a8aadac335..4b5306b1cadc 100644 --- a/codex-rs/direct-tool-timing/Cargo.toml +++ b/codex-rs/direct-tool-timing/Cargo.toml @@ -15,3 +15,6 @@ codex-utils-critical-path = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +test-case = "3.3.1" +tokio = { workspace = true, features = ["macros", "rt", "sync"] } +tokio-util = { workspace = true } diff --git a/codex-rs/direct-tool-timing/src/lib.rs b/codex-rs/direct-tool-timing/src/lib.rs index 4b007deefb9c..193ff6a66ae3 100644 --- a/codex-rs/direct-tool-timing/src/lib.rs +++ b/codex-rs/direct-tool-timing/src/lib.rs @@ -5,3 +5,7 @@ pub use recorder::DirectToolTimings; pub use recorder::ToolBatchTiming; pub use recorder::ToolBlockingTimingGuard; pub use recorder::ToolTimingGuard; + +pub use recorder::AdmittedToolTiming; +pub use recorder::ToolAdmission; +pub use recorder::ToolGateTiming; diff --git a/codex-rs/direct-tool-timing/src/recorder.rs b/codex-rs/direct-tool-timing/src/recorder.rs index 5ae32e768a14..22af5b8d9e26 100644 --- a/codex-rs/direct-tool-timing/src/recorder.rs +++ b/codex-rs/direct-tool-timing/src/recorder.rs @@ -3,13 +3,19 @@ //! Prefixes and dependency paths can contain any work kind; this recorder adds tool and //! review and human-wait intervals, or Unknown for a dependency wait whose history is unavailable. //! Review callbacks must retain their invocation guard and bracket blocking automated -//! reviews with the review guard's lifetime. Execution-lock admission timing is separate. +//! reviews with the review guard's lifetime. Runtime lookup retains weak owners; +//! execution-lock admission timing is separate. use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; use std::time::Instant; +mod runtime; +pub use runtime::AdmittedToolTiming; +pub use runtime::ToolAdmission; +pub use runtime::ToolGateTiming; + use codex_utils_critical_path::CriticalPath; use codex_utils_critical_path::WorkKind; @@ -67,12 +73,19 @@ impl ToolBatchTiming { #[derive(Clone, Default)] pub struct DirectToolTimings(Arc>); +impl std::fmt::Debug for DirectToolTimings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DirectToolTimings").finish_non_exhaustive() + } +} + #[derive(Default)] struct State { calls: HashMap>, } struct DirectToolInvocation { + runtime_owner: Option<(String, std::sync::Weak)>, identity: Arc<()>, path: Option, segment_started_at: Instant, @@ -164,6 +177,7 @@ impl DirectToolTimings { None }; calls.push(DirectToolInvocation { + runtime_owner: None, identity: Arc::clone(&identity), path, segment_started_at: started_at, @@ -191,6 +205,44 @@ impl DirectToolTimings { } } + pub fn start_turn_direct_call( + &self, + call_id: &str, + turn_id: &str, + batch: ToolBatchTiming, + started_at: Instant, + prefix: Option, + ) -> Arc { + let work = Arc::new(self.start_direct_call(call_id, batch, started_at, prefix)); + let mut state = self.state(); + if let Some(invocation) = state.calls.get_mut(call_id).and_then(|calls| { + calls + .iter_mut() + .find(|invocation| Arc::ptr_eq(&invocation.identity, &work.identity)) + }) { + invocation.runtime_owner = Some((turn_id.to_owned(), Arc::downgrade(&work))); + } + work + } + + pub fn start_review_for_turn( + &self, + call_id: &str, + turn_id: &str, + ) -> Option { + self.owner_for_turn(call_id, turn_id)?.start_review() + } + + pub fn owner_for_turn(&self, call_id: &str, turn_id: &str) -> Option> { + let mut state = self.state(); + let (owner_turn, owner) = state.unique_work(call_id)?.runtime_owner.as_ref()?; + if owner_turn != turn_id { + return None; + } + // Retain the invocation identity across registry unlock and later ID reuse. + owner.upgrade() + } + fn finish(&self, call_id: &str, identity: &Arc<()>) -> Option { let mut state = self.state(); let calls = state.calls.get_mut(call_id)?; diff --git a/codex-rs/direct-tool-timing/src/recorder/runtime.rs b/codex-rs/direct-tool-timing/src/recorder/runtime.rs new file mode 100644 index 000000000000..db63a47451a3 --- /dev/null +++ b/codex-rs/direct-tool-timing/src/recorder/runtime.rs @@ -0,0 +1,90 @@ +//! Records runtime execution-lock handoffs. +//! +//! Admission guards must finish before their execution lock is released, so the +//! next admitted invocation can inherit the completed dependency path. + +use super::CompletedWork; +use super::ToolTimingGuard; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Instant; + +#[derive(Clone, Copy)] +pub enum ToolAdmission { + Parallel, + Exclusive, +} + +#[derive(Clone, Default)] +pub struct ToolGateTiming(Arc>); + +#[derive(Default)] +struct GateState { + latest: Option, + writer: Option, +} + +pub struct AdmittedToolTiming { + work: Arc, + gate: ToolGateTiming, + admission: ToolAdmission, +} + +impl ToolGateTiming { + pub fn admit( + &self, + work: Arc, + admission: ToolAdmission, + started_at: Instant, + ) -> AdmittedToolTiming { + let state = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dependency = match admission { + ToolAdmission::Parallel => &state.writer, + ToolAdmission::Exclusive => &state.latest, + }; + if let Some(dependency) = dependency + && dependency.ended_at > started_at + { + work.timings.join( + &work.call_id, + started_at, + Instant::now(), + dependency.path.clone(), + ); + } + AdmittedToolTiming { + work, + gate: self.clone(), + admission, + } + } +} + +impl Drop for AdmittedToolTiming { + fn drop(&mut self) { + if let Some(completed) = self.work.finish() { + let mut state = self + .gate + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!(self.admission, ToolAdmission::Exclusive) { + state.writer = Some(completed.clone()); + } + if state + .latest + .as_ref() + .is_none_or(|previous| previous.ended_at < completed.ended_at) + { + state.latest = Some(completed); + } + } + } +} + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/codex-rs/direct-tool-timing/src/recorder/runtime_tests.rs b/codex-rs/direct-tool-timing/src/recorder/runtime_tests.rs new file mode 100644 index 000000000000..e4571318b9e0 --- /dev/null +++ b/codex-rs/direct-tool-timing/src/recorder/runtime_tests.rs @@ -0,0 +1,99 @@ +#![cfg(test)] +//! Verifies dependency publication at real execution-lock handoffs. + +use super::super::DirectToolTimings; +use super::super::ToolBatchTiming; +use super::*; +use codex_utils_critical_path::CriticalPath; +use codex_utils_critical_path::PathDurations; +use codex_utils_critical_path::WorkKind; +use pretty_assertions::assert_eq; +use std::future::Future; +use std::task::Context; +use std::task::Waker; +use std::time::Duration; +use tokio::sync::RwLock; +use tokio_util::either::Either; + +#[test_case::test_case(ToolAdmission::Parallel; "parallel_after_exclusive")] +#[test_case::test_case(ToolAdmission::Exclusive; "exclusive_after_parallel")] +#[tokio::test] +async fn lock_handoff_publishes_review_history_before_admitting_waiter(admission: ToolAdmission) { + let start = Instant::now() - Duration::from_secs(10); + let tool_start = start + Duration::from_secs(1); + let shared_initial_path = CriticalPath::default().work(start, tool_start, WorkKind::Inference); + let timings = DirectToolTimings::default(); + let batch = ToolBatchTiming::default(); + let gate = ToolGateTiming::default(); + let lock = RwLock::new(()); + let (releasing_lock, releasing_admission) = match admission { + ToolAdmission::Parallel => (Either::Left(lock.write().await), ToolAdmission::Exclusive), + ToolAdmission::Exclusive => (Either::Right(lock.read().await), ToolAdmission::Parallel), + }; + let releasing_work = timings.start_turn_direct_call( + "releasing", + "turn", + batch.clone(), + tool_start, + Some(shared_initial_path.clone()), + ); + let releasing_timing = gate.admit(releasing_work, releasing_admission, tool_start); + // Fixed historical boundaries keep attribution exact without sleeping for a review. + { + let mut state = timings.state(); + let work = state.unique_work("releasing").unwrap(); + work.record_until(start + Duration::from_secs(2)); + work.active_auto_reviews = 1; + work.record_until(start + Duration::from_secs(5)); + work.active_auto_reviews = 0; + } + let waiting_work = timings.start_turn_direct_call( + "waiting", + "turn", + batch.clone(), + tool_start, + Some(shared_initial_path), + ); + let acquire_waiting_lock = async { + match admission { + ToolAdmission::Parallel => Either::Left(lock.read().await), + ToolAdmission::Exclusive => Either::Right(lock.write().await), + } + }; + tokio::pin!(acquire_waiting_lock); + let mut context = Context::from_waker(Waker::noop()); + assert!( + acquire_waiting_lock + .as_mut() + .poll(&mut context) + .is_pending() + ); + + // Runtime cleanup must publish the dependency while it still holds the execution lock. + drop(releasing_timing); + assert!(batch.completed().is_none()); + assert!( + acquire_waiting_lock + .as_mut() + .poll(&mut context) + .is_pending() + ); + drop(releasing_lock); + let waiting_lock = acquire_waiting_lock.await; + let waiting_timing = gate.admit(waiting_work, admission, tool_start); + assert!(batch.completed().is_none()); + drop(waiting_timing); + drop(waiting_lock); + + let completed = batch.completed().unwrap(); + assert_eq!( + completed.path.unwrap().durations(start, completed.ended_at), + PathDurations { + inference: Duration::from_secs(1), + auto_review: Duration::from_secs(3), + tools: completed.ended_at.duration_since(start) - Duration::from_secs(4), + ..PathDurations::default() + } + ); + assert!(timings.state().calls.is_empty()); +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 294f2994d41f..19cc2c1c7d3d 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -203,6 +203,8 @@ pub enum Feature { CwdRelativeTurnDiffs, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, + /// Capture internal direct-tool dependency paths for turn attribution. + CriticalPathTiming, /// Enable startup memory extraction and file-backed memory consolidation. MemoryTool, /// Enable importing project-scoped memory from external agents. @@ -1326,6 +1328,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::CriticalPathTiming, + key: "critical_path_timing", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Sqlite, key: "sqlite", From 536d1f5b682f4d187f1c2929007b0947fe743b21 Mon Sep 17 00:00:00 2001 From: trpcic-openai Date: Sat, 24 Oct 2026 00:19:44 +0000 Subject: [PATCH 030/287] Preserve the tool dispatch span across runtime futures (#57202) ## What changed Instrument the returned tool runtime future with the same dispatch span as the spawned dispatch task, keeping completion and cancellation handling in that span. ## Testing Extend the detached code-mode callback test to assert that the router's `dispatch_tool_call_with_state` span has `dispatch_tool_call_with_code_mode_result` as its parent. GitOrigin-RevId: e4d4bef26563554bf703b3e4f05490e25d8eb0ad --- .../src/tools/code_mode/delegate_tests.rs | 31 +++++++++++++++++-- codex-rs/core/src/tools/parallel.rs | 4 +-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/tools/code_mode/delegate_tests.rs b/codex-rs/core/src/tools/code_mode/delegate_tests.rs index afa7718b0100..805557e69a54 100644 --- a/codex-rs/core/src/tools/code_mode/delegate_tests.rs +++ b/codex-rs/core/src/tools/code_mode/delegate_tests.rs @@ -48,11 +48,25 @@ impl Visit for DispatchFields { } type DispatchRecords = Arc, bool)>>>; +type RouterParents = Arc>>>; -struct DispatchCapture(DispatchRecords); +struct DispatchCapture { + dispatch_records: DispatchRecords, + router_parents: RouterParents, +} impl Layer for DispatchCapture { fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, Registry>) { + if attributes.metadata().target() == "codex_core::tools::router" + && attributes.metadata().name() == "dispatch_tool_call_with_state" + { + let parent = context + .span(id) + .and_then(|span| span.parent()) + .map(|parent| parent.metadata().name()); + self.router_parents.lock().unwrap().push(parent); + return; + } if attributes.metadata().target() != "codex_core::tools::parallel" || attributes.metadata().name() != "dispatch_tool_call_with_code_mode_result" { @@ -66,7 +80,10 @@ impl Layer for DispatchCapture { && ancestor.metadata().name() == "turn" }) }); - self.0.lock().unwrap().push((fields.0, has_turn_ancestor)); + self.dispatch_records + .lock() + .unwrap() + .push((fields.0, has_turn_ancestor)); } } @@ -124,7 +141,11 @@ async fn detached_code_mode_callback_keeps_thread_id_on_dispatch_span() -> anyho let cell_id = CellId::new("audit-cell".to_string()); broker.mark_cell_ready_for_dispatch(&cell_id, /*originating_call*/ None); let records = DispatchRecords::default(); - let subscriber = tracing_subscriber::registry().with(DispatchCapture(Arc::clone(&records))); + let router_parents = RouterParents::default(); + let subscriber = tracing_subscriber::registry().with(DispatchCapture { + dispatch_records: Arc::clone(&records), + router_parents: Arc::clone(&router_parents), + }); let _untraced = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default()); let _subscriber = tracing::subscriber::set_default(subscriber); let delegate = CodeModeCellDelegate { @@ -171,6 +192,10 @@ async fn detached_code_mode_callback_keeps_thread_id_on_dispatch_span() -> anyho .strip_prefix("exec-") .expect("nested call ID must belong to code mode"); uuid::Uuid::parse_str(uuid)?; + assert_eq!( + router_parents.lock().unwrap().as_slice(), + [Some("dispatch_tool_call_with_code_mode_result")] + ); Ok(()) } diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 7d11aff46394..09fd9000df0c 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -341,7 +341,7 @@ impl ToolCallRuntime { } result } - .instrument(dispatch_span.or_current()), + .instrument(dispatch_span.clone().or_current()), )); async move { @@ -416,7 +416,7 @@ impl ToolCallRuntime { } result } - .in_current_span() + .instrument(dispatch_span.or_current()) } } From beb34ce8d09779e1e4ed69069fce1c989a6c332e Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Sat, 24 Oct 2026 00:51:24 +0000 Subject: [PATCH 031/287] Preserve command cwd in permission and post-tool hooks (#57203) ## Why `PermissionRequest` and `PostToolUse` hook inputs could report the turn's working directory instead of the local command's execution directory. This also affected commands whose results were collected in a later turn after the working directory changed. ## What changed - Report the local command's execution directory in hook input `cwd`, consistent with `PreToolUse`. - Preserve the original directory through `write_stdin` approvals and delayed completion, including network permission requests. - Retain the host directory for remote commands and keep hook process working directories unchanged. ## Testing Add coverage for relative and absolute workdirs, approval denials, command rewrites, completion and stdin approval across turns, remote command fallback, network approvals, and Unix and Windows path formatting. GitOrigin-RevId: ef5dad2a0634dda9edb2f841ef4fe49ae0d1187d --- codex-rs/core/src/hook_runtime.rs | 23 +- codex-rs/core/src/tools/approvals.rs | 31 +- codex-rs/core/src/tools/approvals_tests.rs | 55 +++ codex-rs/core/src/tools/context.rs | 8 + codex-rs/core/src/tools/context_tests.rs | 3 + .../core/src/tools/handlers/apply_patch.rs | 1 + .../src/tools/handlers/apply_patch_tests.rs | 1 + .../src/tools/handlers/extension_tools.rs | 1 + codex-rs/core/src/tools/handlers/mcp.rs | 2 + .../core/src/tools/handlers/unified_exec.rs | 1 + .../handlers/unified_exec/exec_command.rs | 4 +- .../src/tools/handlers/unified_exec_tests.rs | 15 + codex-rs/core/src/tools/registry.rs | 9 +- codex-rs/core/src/tools/registry_tests.rs | 1 + codex-rs/core/src/tools/sandboxing.rs | 4 +- codex-rs/core/src/tools/sandboxing_tests.rs | 9 +- .../core/src/tools/user_messaging_tests.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 1 + .../core/src/unified_exec/process_manager.rs | 9 + codex-rs/core/tests/suite/hooks.rs | 24 +- codex-rs/core/tests/suite/hooks_cwd_tests.rs | 340 +++++++++++++++++- codex-rs/hooks/src/events/common.rs | 10 + .../hooks/src/events/permission_request.rs | 37 +- codex-rs/hooks/src/events/post_tool_use.rs | 23 +- codex-rs/hooks/src/events/pre_tool_use.rs | 5 +- codex-rs/tools/src/tool_output.rs | 12 + 26 files changed, 598 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index bea5f8cae991..603a6e42604c 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -71,6 +71,7 @@ use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::state::TurnState; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::PostToolUsePayload; use crate::tools::sandboxing::PermissionRequestPayload; use crate::turn_metadata::ExecutionMetadata; @@ -281,6 +282,7 @@ pub(crate) async fn run_permission_request_hooks( turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), cwd: tool_hook_cwd(review_context.environments(), turn_context).to_path_buf(), + tool_cwd: payload.tool_cwd, transcript_path: sess.hook_transcript_path().await, model: review_context.model_info.slug.clone(), permission_mode: hook_permission_mode(review_context.approval_policy), @@ -309,19 +311,13 @@ pub(crate) async fn run_permission_request_hooks( /// Runs matching `PostToolUse` hooks after a tool has produced a successful output. /// -/// The `tool_name`, matcher aliases, `tool_input`, and `tool_response` values are -/// already adapted by the tool handler into the stable hook contract. Passing +/// The payload is already adapted by the tool handler into the stable hook contract. Passing /// raw internal tool data here would leak implementation details into user hook /// matchers and hook logs. -#[allow(clippy::too_many_arguments)] pub(crate) async fn run_post_tool_use_hooks( sess: &Arc, step_context: &StepContext, - tool_use_id: String, - tool_name: String, - matcher_aliases: Vec, - tool_input: Value, - tool_response: Value, + payload: PostToolUsePayload, target_environments: &[&str], ) -> PostToolUseOutcome { let turn_context = &step_context.turn; @@ -330,14 +326,15 @@ pub(crate) async fn run_post_tool_use_hooks( turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), cwd: tool_hook_cwd(&step_context.environments, turn_context), + tool_cwd: payload.tool_cwd, transcript_path: sess.hook_transcript_path().await, model: step_context.settings.model_info.slug.clone(), permission_mode: hook_permission_mode(step_context.settings.approval_policy()), - tool_name, - matcher_aliases, - tool_use_id, - tool_input, - tool_response, + tool_name: payload.tool_name.name().to_string(), + matcher_aliases: payload.tool_name.matcher_aliases().to_vec(), + tool_use_id: payload.tool_use_id, + tool_input: payload.tool_input, + tool_response: payload.tool_response, }; let hooks = with_tool_environment_hooks( sess, diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 245a63878ad8..22823beb6a08 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -166,12 +166,17 @@ impl ApprovalAction { } pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload { - match self { + let mut payload = match self { Self::ExecCommand { hook_command, justification, + cwd, .. - } => PermissionRequestPayload::bash(hook_command.clone(), justification.clone()), + } => PermissionRequestPayload::bash( + hook_command.clone(), + justification.clone(), + cwd.clone(), + ), Self::WriteStdin { id, approval_id, @@ -185,6 +190,7 @@ impl ApprovalAction { additional_permissions_preapproved: _, } => PermissionRequestPayload { tool_name: HookToolName::new("write_stdin"), + tool_cwd: Some(cwd.clone()), tool_input: serde_json::json!({ "session_id": process_id, "chars": input, @@ -199,6 +205,7 @@ impl ApprovalAction { }, Self::ApplyPatch { patch, .. } => PermissionRequestPayload { tool_name: HookToolName::apply_patch(), + tool_cwd: None, tool_input: serde_json::json!({ "command": patch }), }, Self::McpToolCall { @@ -207,6 +214,7 @@ impl ApprovalAction { .. } => PermissionRequestPayload { tool_name: hook_tool_name.clone(), + tool_cwd: None, tool_input: arguments .clone() .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())), @@ -214,10 +222,16 @@ impl ApprovalAction { Self::NetworkAccess { hook_command, target, + cwd, + trigger, .. } => PermissionRequestPayload::bash( hook_command.clone(), Some(format!("network-access {target}")), + trigger.as_ref().map_or_else( + || PathUri::from_abs_path(cwd), + |trigger| trigger.cwd.clone(), + ), ), Self::RequestPermissions { reason, @@ -225,12 +239,25 @@ impl ApprovalAction { .. } => PermissionRequestPayload { tool_name: HookToolName::new("request_permissions"), + tool_cwd: None, tool_input: serde_json::json!({ "reason": reason, "permissions": permissions, }), }, + }; + // Command hooks run on the host. Keep remote paths in the action's + // original tool input, but never use them as the hook's top-level cwd. + if matches!( + self, + Self::ExecCommand { environment_id, .. } + | Self::WriteStdin { environment_id, .. } + | Self::NetworkAccess { environment_id, .. } + if environment_id != codex_exec_server::LOCAL_ENVIRONMENT_ID + ) { + payload.tool_cwd = None; } + payload } pub(crate) fn cache_keys(&self) -> Vec { diff --git a/codex-rs/core/src/tools/approvals_tests.rs b/codex-rs/core/src/tools/approvals_tests.rs index c7a131c59af9..3620a3cd815e 100644 --- a/codex-rs/core/src/tools/approvals_tests.rs +++ b/codex-rs/core/src/tools/approvals_tests.rs @@ -243,3 +243,58 @@ async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() { } } } + +#[test] +fn network_hook_cwd_overrides_only_local_commands() { + let local_cwd = AbsolutePathBuf::try_from(std::env::temp_dir()).unwrap(); + for (environment_id, uri) in [ + (codex_exec_server::LOCAL_ENVIRONMENT_ID, None), + ( + codex_exec_server::LOCAL_ENVIRONMENT_ID, + Some("file:///local/repo"), + ), + ("remote", None), + ("remote", Some("file:///remote/repo")), + ("remote", Some("file:///C:/remote/repo")), + ] { + let expected_cwd = uri.map_or_else( + || PathUri::from_abs_path(&local_cwd), + |uri| PathUri::parse(uri).unwrap(), + ); + let action = ApprovalAction::NetworkAccess { + id: "network-1".into(), + turn_id: "turn-1".into(), + environment_id: environment_id.into(), + target: "https://example.com:443".into(), + host: "example.com".into(), + protocol: NetworkApprovalProtocol::Https, + port: 443, + trigger: uri.map(|_| GuardianNetworkAccessTrigger { + call_id: "exec-1".into(), + tool_name: "exec_command".into(), + command: vec!["curl".into(), "https://example.com".into()], + cwd: expected_cwd.clone(), + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, + justification: None, + tty: None, + }), + hook_command: "curl https://example.com".into(), + hook_run_id: "hook-1".into(), + command: vec!["network-access".into(), "https://example.com:443".into()], + cwd: local_cwd.clone(), + }; + assert_eq!( + action.permission_request_payload(), + PermissionRequestPayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ + "command": "curl https://example.com", + "description": "network-access https://example.com:443", + }), + tool_cwd: (environment_id == codex_exec_server::LOCAL_ENVIRONMENT_ID) + .then_some(expected_cwd), + } + ); + } +} diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 474fa619181b..bb9fd88a777e 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -20,6 +20,7 @@ use codex_utils_audio::estimate_audio_token_count; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_function_output_payload; use codex_utils_output_truncation::with_serialization_allowance; +use codex_utils_path_uri::PathUri; use serde::Serialize; use serde_json::Value as JsonValue; use std::sync::Arc; @@ -429,6 +430,9 @@ pub struct ExecCommandToolOutput { pub exit_code: Option, pub original_token_count: Option, pub hook_command: Option, + /// Local execution cwd retained for PostToolUse, independent of the collecting + /// turn. Remote commands leave this unset to retain the host hook cwd. + pub tool_cwd: Option, } impl ToolOutput for ExecCommandToolOutput { @@ -469,6 +473,10 @@ impl ToolOutput for ExecCommandToolOutput { .map(|command| serde_json::json!({ "command": command })) } + fn post_tool_use_cwd(&self) -> Option { + self.tool_cwd.clone() + } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { if self.process_id.is_some() || self.hook_command.is_none() { return None; diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index 7781ddccd9a9..aa9a5f37e52a 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -425,6 +425,7 @@ fn exec_command_tool_output_formats_truncated_response() { exit_code: Some(0), original_token_count: Some(10), hook_command: None, + tool_cwd: None, }; assert_eq!( output.log_output(), @@ -480,6 +481,7 @@ fn exec_command_tool_output_reserves_metadata_budget_and_preserves_policy_units( exit_code: Some(0), original_token_count: Some(123), hook_command: None, + tool_cwd: None, } .to_response_item("call-42", &payload); @@ -525,6 +527,7 @@ fn exec_command_tool_output_preserves_omission_metadata_when_truncated() { exit_code: Some(0), original_token_count: Some(42_000), hook_command: None, + tool_cwd: None, }; let expected_header = "Chunk ID: abc123\nWall time: 1.2500 seconds\nProcess exited with code 0\nOriginal token count: 42000\nOutput:\n"; assert_eq!( diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 86d3512faf00..cc414484ca57 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -449,6 +449,7 @@ impl CoreToolRuntime for ApplyPatchHandler { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { + tool_cwd: result.post_tool_use_cwd(), tool_name: HookToolName::apply_patch(), tool_use_id: invocation.call_id.clone(), tool_input: serde_json::json!({ diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index a89fed5bf31b..64d118a58ac7 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -90,6 +90,7 @@ async fn post_tool_use_payload_uses_patch_input_and_tool_output() { assert_eq!( handler.post_tool_use_payload(&invocation, &output), Some(PostToolUsePayload { + tool_cwd: None, tool_name: HookToolName::apply_patch(), tool_use_id: "call-apply-patch".to_string(), tool_input: json!({ "command": patch }), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 5c499ebb0c70..a6cfe8fac99c 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -449,6 +449,7 @@ mod tests { assert_eq!( CoreToolRuntime::post_tool_use_payload(&handler, &invocation, &output), Some(PostToolUsePayload { + tool_cwd: None, tool_name: HookToolName::new("extension_echo"), tool_use_id: "call-extension".to_string(), tool_input: json!({ "message": "hello" }), diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index c607b341ff67..bfbd9ef21bfc 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -545,6 +545,7 @@ impl CoreToolRuntime for McpHandler { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { + tool_cwd: result.post_tool_use_cwd(), tool_name: self.hook_tool_name(), tool_use_id: invocation.call_id.clone(), tool_input: result.post_tool_use_input(&invocation.payload)?, @@ -834,6 +835,7 @@ mod tests { assert_eq!( handler.post_tool_use_payload(&invocation, &output), Some(PostToolUsePayload { + tool_cwd: None, tool_name: HookToolName::new("mcp__filesystem__read_file"), tool_use_id: "call-mcp-post".to_string(), tool_input: json!({ diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 3448b427f051..9164d8419674 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -139,6 +139,7 @@ fn post_unified_exec_tool_use_payload( let tool_use_id = result.post_tool_use_id(&invocation.call_id); let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?; Some(PostToolUsePayload { + tool_cwd: result.post_tool_use_cwd(), tool_name: HookToolName::bash(), tool_use_id, tool_input, diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index e4dbf146e9f2..c025d534a872 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -377,6 +377,7 @@ impl ExecCommandHandler { exit_code: None, original_token_count: None, hook_command: None, + tool_cwd: None, })); } @@ -399,7 +400,7 @@ impl ExecCommandHandler { process_id, yield_time_ms, max_output_tokens, - cwd, + cwd: cwd.clone(), sandbox_cwd: turn_environment.cwd().clone(), turn_environment: turn_environment.clone(), network: context.step_context.turn.network.clone(), @@ -445,6 +446,7 @@ impl ExecCommandHandler { exit_code: Some(output.exit_code), original_token_count: Some(original_token_count), hook_command: Some(hook_command), + tool_cwd: (!turn_environment.environment.is_remote()).then_some(cwd), })) } Err(err) => { diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index d1a76fad9506..20715efbacad 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -604,12 +604,14 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s exit_code: Some(0), original_token_count: None, hook_command: Some("echo three".to_string()), + tool_cwd: Some(PathUri::parse("file:///C:/workspace").expect("valid Windows cwd")), }; let invocation = invocation_for_payload("exec_command", "call-43", payload).await; let handler = ExecCommandHandler::default(); assert_eq!( handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { + tool_cwd: Some(PathUri::parse("file:///C:/workspace").expect("valid Windows cwd")), tool_name: HookToolName::bash(), tool_use_id: "call-43".to_string(), tool_input: serde_json::json!({ "command": "echo three" }), @@ -634,6 +636,7 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi exit_code: Some(0), original_token_count: None, hook_command: Some("echo three".to_string()), + tool_cwd: None, }; let invocation = invocation_for_payload("exec_command", "call-44", payload).await; let handler = ExecCommandHandler::default(); @@ -641,6 +644,7 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi assert_eq!( handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { + tool_cwd: None, tool_name: HookToolName::bash(), tool_use_id: "call-44".to_string(), tool_input: serde_json::json!({ "command": "echo three" }), @@ -665,6 +669,7 @@ async fn exec_command_post_tool_use_payload_skips_running_sessions() { exit_code: None, original_token_count: None, hook_command: Some("echo three".to_string()), + tool_cwd: None, }; let invocation = invocation_for_payload("exec_command", "call-45", payload).await; let handler = ExecCommandHandler::default(); @@ -691,6 +696,7 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman exit_code: Some(0), original_token_count: None, hook_command: Some("sleep 1; echo finished".to_string()), + tool_cwd: None, }; let invocation = invocation_for_payload("write_stdin", "write-stdin-call", payload).await; let handler = WriteStdinHandler; @@ -698,6 +704,7 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman assert_eq!( handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { + tool_cwd: None, tool_name: HookToolName::bash(), tool_use_id: "exec-call-45".to_string(), tool_input: serde_json::json!({ "command": "sleep 1; echo finished" }), @@ -722,6 +729,7 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ exit_code: Some(0), original_token_count: None, hook_command: Some("sleep 2; echo alpha".to_string()), + tool_cwd: Some(PathUri::parse("file:///C:/workspace/alpha").expect("valid Windows cwd")), }; let output_b = ExecCommandToolOutput { event_call_id: "exec-call-b".to_string(), @@ -734,6 +742,7 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ exit_code: Some(0), original_token_count: None, hook_command: Some("sleep 1; echo beta".to_string()), + tool_cwd: Some(PathUri::parse("file:///C:/workspace/beta").expect("valid Windows cwd")), }; let invocation_b = invocation_for_payload("write_stdin", "write-call-b", payload.clone()).await; let invocation_a = invocation_for_payload("write_stdin", "write-call-a", payload).await; @@ -748,12 +757,18 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ payloads, [ Some(crate::tools::registry::PostToolUsePayload { + tool_cwd: Some( + PathUri::parse("file:///C:/workspace/beta").expect("valid Windows cwd") + ), tool_name: HookToolName::bash(), tool_use_id: "exec-call-b".to_string(), tool_input: serde_json::json!({ "command": "sleep 1; echo beta" }), tool_response: serde_json::json!("beta\n"), }), Some(crate::tools::registry::PostToolUsePayload { + tool_cwd: Some( + PathUri::parse("file:///C:/workspace/alpha").expect("valid Windows cwd") + ), tool_name: HookToolName::bash(), tool_use_id: "exec-call-a".to_string(), tool_input: serde_json::json!({ "command": "sleep 2; echo alpha" }), diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index b42bce6f238d..392ac9a44cf5 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -141,6 +141,7 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { }; Some(PostToolUsePayload { + tool_cwd: result.post_tool_use_cwd(), tool_name: function_hook_tool_name(invocation), tool_use_id: result.post_tool_use_id(&invocation.call_id), tool_input: result @@ -375,6 +376,8 @@ pub(crate) struct PreToolUsePayload { #[derive(Debug, Clone, PartialEq)] pub(crate) struct PostToolUsePayload { + /// Local execution cwd used by hook input; remote tools retain the host fallback. + pub(crate) tool_cwd: Option, /// Hook-facing tool name model. /// /// The canonical name is serialized to hook stdin, while aliases are used @@ -934,11 +937,7 @@ impl ToolRegistry { run_post_tool_use_hooks( &invocation.session, invocation.step_context.as_ref(), - post_tool_use_payload.tool_use_id, - post_tool_use_payload.tool_name.name().to_string(), - post_tool_use_payload.tool_name.matcher_aliases().to_vec(), - post_tool_use_payload.tool_input, - post_tool_use_payload.tool_response, + post_tool_use_payload, &invocation.environments.hook_ids(), ) .or_cancel_tool(&cancellation_token) diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 0cf26b084a09..8d6e5f9de0c8 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -470,6 +470,7 @@ async fn function_tools_expose_default_hook_payloads_and_rewrites() -> anyhow::R assert_eq!( handler.post_tool_use_payload(&invocation, &output), Some(PostToolUsePayload { + tool_cwd: None, tool_name: HookToolName::new("functions.echo"), tool_use_id: "call-1".to_string(), tool_input: serde_json::json!({ "message": "hello" }), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index d49273beb37f..5a51a5dc6b9f 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -129,10 +129,11 @@ pub(crate) use super::approvals::ApprovalAction; pub(crate) struct PermissionRequestPayload { pub tool_name: HookToolName, pub tool_input: serde_json::Value, + pub tool_cwd: Option, } impl PermissionRequestPayload { - pub(crate) fn bash(command: String, description: Option) -> Self { + pub(crate) fn bash(command: String, description: Option, cwd: PathUri) -> Self { let mut tool_input = serde_json::Map::new(); tool_input.insert("command".to_string(), serde_json::Value::String(command)); if let Some(description) = description { @@ -145,6 +146,7 @@ impl PermissionRequestPayload { Self { tool_name: HookToolName::bash(), tool_input: serde_json::Value::Object(tool_input), + tool_cwd: Some(cwd), } } } diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index a4839cfd33da..3c2cf95933be 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -21,9 +21,14 @@ use std::collections::HashMap; #[test] fn bash_permission_request_payload_omits_missing_description() { assert_eq!( - PermissionRequestPayload::bash("echo hi".to_string(), /*description*/ None), + PermissionRequestPayload::bash( + "echo hi".to_string(), + /*description*/ None, + PathUri::parse("file:///repo-B").unwrap() + ), PermissionRequestPayload { tool_name: HookToolName::bash(), + tool_cwd: Some(PathUri::parse("file:///repo-B").unwrap()), tool_input: json!({ "command": "echo hi" }), } ); @@ -35,9 +40,11 @@ fn bash_permission_request_payload_includes_description_when_present() { PermissionRequestPayload::bash( "echo hi".to_string(), Some("network-access example.com".to_string()), + PathUri::parse("file:///repo-B").unwrap(), ), PermissionRequestPayload { tool_name: HookToolName::bash(), + tool_cwd: Some(PathUri::parse("file:///repo-B").unwrap()), tool_input: json!({ "command": "echo hi", "description": "network-access example.com", diff --git a/codex-rs/core/src/tools/user_messaging_tests.rs b/codex-rs/core/src/tools/user_messaging_tests.rs index 6a46eb3c188f..70b927d3294b 100644 --- a/codex-rs/core/src/tools/user_messaging_tests.rs +++ b/codex-rs/core/src/tools/user_messaging_tests.rs @@ -47,6 +47,7 @@ fn messaging_result(name: &str, input: Value, success: bool) -> AnyToolResult { Some(success), )), post_tool_use_payload: Some(PostToolUsePayload { + tool_cwd: None, tool_name: HookToolName::new(name), tool_use_id: "message-call".to_owned(), tool_input: input, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 6be0a6ec6a5b..4bc5847102eb 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -212,6 +212,7 @@ async fn exec_command_with_tty( exit_code, original_token_count: Some(original_token_count), hook_command: Some(cmd.to_string()), + tool_cwd: None, }) } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index b55848d9b180..9516b3e32a66 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -266,6 +266,7 @@ struct PreparedProcessHandles { network_approval: Option, call_id: String, hook_command: String, + tool_cwd: Option, process_id: i32, tty: bool, } @@ -874,6 +875,8 @@ impl UnifiedExecProcessManager { exit_code, original_token_count: Some(original_token_count), hook_command: Some(request.hook_command.clone()), + tool_cwd: (!request.turn_environment.environment.is_remote()) + .then(|| request.cwd.clone()), }; Ok(response) @@ -996,6 +999,7 @@ impl UnifiedExecProcessManager { network_approval, call_id, hook_command, + tool_cwd, process_id, tty, .. @@ -1126,6 +1130,7 @@ impl UnifiedExecProcessManager { exit_code, original_token_count: Some(original_token_count), hook_command: Some(hook_command), + tool_cwd, }; let should_emit_interaction = !request.input.is_empty() || response.process_id.is_some(); @@ -1230,6 +1235,10 @@ impl UnifiedExecProcessManager { network_approval: entry.network_approval.clone(), call_id: entry.call_id.clone(), hook_command: entry.hook_command.clone(), + // Capture the original process's locality before it can be removed + // from the store. A later turn may select a different environment. + tool_cwd: (entry.environment_id == codex_exec_server::LOCAL_ENVIRONMENT_ID) + .then(|| entry.cwd.clone()), process_id: entry.process_id, tty: entry.tty, }) diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index a31b55a5d41d..175b68983c99 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3441,7 +3441,7 @@ mode = "limited" allow_local_binding = true "#, )?; - let args = serde_json::json!({ "cmd": command }); + let args = serde_json::json!({ "cmd": command, "workdir": "network-workdir" }); let responses = mount_sse_sequence( &server, vec![ @@ -3472,6 +3472,14 @@ allow_local_binding = true hook_reason, ) .expect("failed to write permission request hook test fixture"); + let script_path = home.join("permission_request_hook.py"); + let script = fs::read_to_string(&script_path) + .expect("read permission request hook script") + .replace( + "payload = json.load(sys.stdin)", + "payload = json.load(sys.stdin)\npayload['process_cwd'] = str(Path.cwd())", + ); + fs::write(script_path, script).expect("record network hook process cwd"); }) .with_cloud_config_bundle( CloudConfigBundleFixture::loader_with_enterprise_requirement( @@ -3495,6 +3503,8 @@ review_domains = ["codex-network-test.invalid"] }) .build(&server) .await?; + let execution_cwd = test.workspace_path_uri("network-workdir")?; + fs::create_dir(execution_cwd.to_abs_path()?).context("create network command workdir")?; test.submit_turn_with_approval_and_permission_profile( "run the shell command after the network permission hook", @@ -3531,11 +3541,21 @@ review_domains = ["codex-network-test.invalid"] ); } - assert_single_permission_request_hook_input( + let hook_inputs = assert_single_permission_request_hook_input( test.codex_home_path(), command, Some("network-access http://codex-network-test.invalid:80"), )?; + assert_eq!( + json!({ + "cwd": hook_inputs[0]["cwd"], + "process_cwd": hook_inputs[0]["process_cwd"], + }), + json!({ + "cwd": execution_cwd.inferred_native_path_string(), + "process_cwd": dunce::canonicalize(&test.config.cwd)?.to_string_lossy(), + }), + ); let requests = responses.requests(); assert_eq!( requests diff --git a/codex-rs/core/tests/suite/hooks_cwd_tests.rs b/codex-rs/core/tests/suite/hooks_cwd_tests.rs index de371808bac3..3fa45226dc00 100644 --- a/codex-rs/core/tests/suite/hooks_cwd_tests.rs +++ b/codex-rs/core/tests/suite/hooks_cwd_tests.rs @@ -1,6 +1,6 @@ #![cfg(test)] -//! PreToolUse reports local command cwd and retains host cwd for remote commands. +//! Tool hooks report local command cwd and retain host cwd for remote commands. use super::*; use codex_exec_server::CreateDirectoryOptions; @@ -213,3 +213,341 @@ async fn invalid_execution_cwd_records_failure_without_running_hooks() -> Result } Ok(()) } + +#[test_case(None, "allow"; "default")] +#[test_case(Some(""), "allow"; "empty")] +#[test_case(Some("nested/../nested"), "allow"; "relative")] +#[test_case(Some("absolute"), "allow"; "absolute")] +#[test_case(Some("nested"), "deny"; "permission_denied")] +#[test_case(Some("nested"), "rewrite"; "rewritten")] +#[test_case(Some("nested"), "delayed"; "completion_in_another_turn")] +#[test_case(Some("nested"), "stdin"; "stdin_approval_in_another_turn")] +#[tokio::test] +async fn command_hooks_keep_execution_cwd(workdir: Option<&str>, mode: &'static str) -> Result<()> { + use codex_protocol::protocol::TurnEnvironmentRequests; + + skip_if_no_network!(Ok(())); + if mode == "stdin" { + core_test_support::skip_if_target_windows!(Ok(()), "uses a POSIX read/tty fixture"); + } + let later_turn = matches!(mode, "delayed" | "stdin"); + let server = start_mock_server().await; + let test = test_codex() + .with_pre_build_hook(move |home| { + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +payload['process_cwd'] = str(Path.cwd()) +with Path({log_path:?}).open('a', encoding='utf-8') as log: + log.write(json.dumps(payload) + '\n') +event = payload['hook_event_name'] +if event == 'PermissionRequest': + print(json.dumps({{'hookSpecificOutput': {{'hookEventName': event, 'decision': {{ + 'behavior': 'deny' if {mode:?} == 'deny' else 'allow', 'message': 'cwd test denied' + }}}}}})) +elif event == 'PreToolUse' and {mode:?} == 'rewrite': + print(json.dumps({{'hookSpecificOutput': {{'hookEventName': event, + 'permissionDecision': 'allow', + 'updatedInput': {{'command': 'cat rewritten-marker.txt'}} + }}}})) +"#, + log_path = home.join("tool_cwd_hook_log.jsonl").to_string_lossy() + ); + let script_path = home.join("tool_cwd_hook.py"); + fs::write(&script_path, script).expect("write hook recorder"); + let group = json!([{"matcher": "^(Bash|write_stdin)$", "hooks": [{ + "type": "command", "command": format!("python3 {}", script_path.display()), + }]}]); + fs::write( + home.join("hooks.json"), + json!({"hooks": { + "PreToolUse": group, "PermissionRequest": group, "PostToolUse": group, + }}) + .to_string(), + ) + .expect("write hook configuration"); + }) + .with_config(move |config| { + trust_discovered_hooks(config); + if mode == "stdin" { + config + .features + .enable(Feature::WriteStdinApproval) + .expect("enable stdin approval"); + } + }) + .build_with_remote_and_local_env(&server) + .await?; + // Remote command paths never replace the host cwd in hook input. + let local_hook_directory = TempDir::new()?; + let mut environments = vec![test.executor_environment().request()]; + let hook_cwd = if test.executor_environment().environment().is_remote() { + let cwd = AbsolutePathBuf::try_from(local_hook_directory.path())?; + environments.push(core_test_support::test_codex::local_request(cwd.clone())); + cwd + } else { + test.config.cwd.clone() + }; + let root = test.executor_environment().selection().cwd.clone(); + let nested = root.join("nested")?; + let other = root.join("other")?; + for dir in [&nested, &other] { + test.fs() + .create_directory( + dir, + CreateDirectoryOptions { + recursive: true, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + } + for (dir, marker) in [(&root, "root"), (&nested, "nested")] { + for filename in ["cwd-marker.txt", "rewritten-marker.txt"] { + test.fs() + .write_file( + &dir.join(filename)?, + format!("{marker}:{filename}").into_bytes(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + } + } + let command = if mode == "stdin" { + "read -r line; cat cwd-marker.txt" + } else if mode == "delayed" { + match test_target_os() { + TestTargetOs::Windows => { + "while (!(Test-Path release.txt)) { Start-Sleep -Milliseconds 50 }; Get-Content cwd-marker.txt" + } + TestTargetOs::Linux | TestTargetOs::MacOs => { + "while [ ! -f release.txt ]; do sleep 0.05; done; cat cwd-marker.txt" + } + } + } else { + "cat cwd-marker.txt" + }; + // A cold remote shell can take more than a second to start, especially under Wine. + // Only the cross-turn cases intentionally return before the command completes. + let yield_time_ms = if later_turn { 1000 } else { 30000 }; + let mut args = json!({"cmd": command, "login": false, "yield_time_ms": yield_time_ms, + "sandbox_permissions": "require_escalated"}); + if mode == "stdin" { + args["tty"] = json!(true); + } + if let Some(workdir) = workdir { + args["workdir"] = if workdir == "absolute" { + json!(nested.inferred_native_path_string()) + } else { + json!(workdir) + }; + } + let call_id = "command-cwd"; + let mut response_events = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &args.to_string()), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + if later_turn { + response_events.extend([ + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + "poll-cwd", + "write_stdin", + &json!({ + "session_id": 1000, "chars": if mode == "stdin" { "go\n" } else { "" }, "yield_time_ms": 5000, + }) + .to_string(), + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-2", "collected"), + ev_completed("resp-4"), + ]), + ]); + } + let responses = mount_sse_sequence(&server, response_events).await; + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::read_only(), test.config.cwd.as_path()); + let turn_settings = ThreadSettingsOverrides { + environments: Some(TurnEnvironmentRequests::new( + test.config.cwd.clone(), + environments.clone(), + )), + approval_policy: Some(AskForApproval::OnRequest), + sandbox_policy: Some(sandbox_policy), + permission_profile, + ..Default::default() + }; + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "run the command".into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(turn_settings.clone()), + ) + .await?; + wait_for_event(&test.codex, |event| { + assert!( + !matches!(event, EventMsg::ExecApprovalRequest(_)), + "hook should decide approval" + ); + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let log_path = test.codex_home_path().join("tool_cwd_hook_log.jsonl"); + let expected_cwd = if workdir.is_none_or(str::is_empty) { + &root + } else { + &nested + }; + let mut post_process_cwd = dunce::canonicalize(&hook_cwd)? + .to_string_lossy() + .into_owned(); + let expected_hook_cwd = if test.executor_environment().environment().is_remote() { + post_process_cwd.clone() + } else { + expected_cwd.inferred_native_path_string() + }; + if later_turn { + let first_output = responses.requests()[1].function_call_output(call_id); + assert!( + first_output["output"] + .as_str() + .context("running command output")? + .contains("Process running with session ID 1000") + ); + assert_eq!( + fs::read_to_string(&log_path)?.lines().count(), + 2, + "no post hook until completion" + ); + if mode == "delayed" { + test.fs() + .write_file( + &expected_cwd.join("release.txt")?, + Vec::new(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + } + environments[0].cwd = other.clone(); + if !test.executor_environment().environment().is_remote() { + post_process_cwd = dunce::canonicalize(other.to_abs_path()?.as_path())? + .to_string_lossy() + .into_owned(); + } + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "collect the command result".into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some(TurnEnvironmentRequests::new( + test.config.cwd.clone(), + environments, + )), + ..turn_settings + }), + ) + .await?; + wait_for_event(&test.codex, |event| { + assert!( + !matches!(event, EventMsg::ExecApprovalRequest(_)), + "hook should decide stdin approval" + ); + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + let inputs: Vec = fs::read_to_string(log_path)? + .lines() + .map(serde_json::from_str) + .collect::>()?; + let executed_command = if mode == "rewrite" { + "cat rewritten-marker.txt" + } else { + command + }; + let mut expected = vec![]; + for (event, event_command) in [ + ("PreToolUse", command), + ("PermissionRequest", executed_command), + ("PostToolUse", executed_command), + ] { + if mode == "deny" && event == "PostToolUse" { + continue; + } + if mode == "stdin" && event == "PostToolUse" { + expected.push(json!({ + "event": "PermissionRequest", "tool_name": "write_stdin", + "cwd": expected_hook_cwd, "process_cwd": post_process_cwd, + "tool_input": { + "session_id": 1000, "chars": "go\n", "parent_call_id": call_id, + "approval_id": "poll-cwd", "environment_id": test.executor_environment().selection().environment_id, + "cwd": expected_cwd, "tty": true, "sandbox_permissions": "require_escalated", + "additional_permissions": null, + }, + })); + } + expected.push(json!({ + "event": event, "tool_name": "Bash", "cwd": expected_hook_cwd, + "process_cwd": if event == "PostToolUse" { post_process_cwd.clone() } else { dunce::canonicalize(&hook_cwd)?.to_string_lossy().into_owned() }, + "tool_input": {"command": event_command}, + })); + } + assert_eq!( + inputs + .iter() + .map(|input| json!({ + "event": input["hook_event_name"], "tool_name": input["tool_name"], "cwd": input["cwd"], + "process_cwd": input["process_cwd"], "tool_input": input["tool_input"], + })) + .collect::>(), + expected + ); + let requests = responses.requests(); + let output = requests + .last() + .context("final model request")? + .function_call_output(if later_turn { "poll-cwd" } else { call_id }); + let output = output["output"].as_str().context("command result")?; + if mode == "deny" { + assert!(output.contains("cwd test denied"), "{output}"); + } else { + let filename = if mode == "rewrite" { + "rewritten-marker.txt" + } else { + "cwd-marker.txt" + }; + let marker = if workdir.is_none_or(str::is_empty) { + "root" + } else { + "nested" + }; + assert!(output.contains(&format!("{marker}:{filename}")), "{output}"); + assert_eq!( + inputs.last().context("completion hook input")?["tool_use_id"], + call_id + ); + } + Ok(()) +} diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 735e0b4be9f5..1d1bcb66929e 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -1,9 +1,12 @@ +use std::path::Path; + use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; +use codex_utils_path_uri::PathUri; use crate::engine::ConfiguredHandler; use crate::engine::HandlerSourcePath; @@ -18,6 +21,13 @@ pub struct SubagentHookContext { pub agent_type: String, } +pub(crate) fn command_input_cwd(cwd: &Path, tool_cwd: Option<&PathUri>) -> String { + tool_cwd.map_or_else( + || cwd.display().to_string(), + PathUri::inferred_native_path_string, + ) +} + pub(crate) fn join_text_chunks(chunks: Vec) -> Option { if chunks.is_empty() { None diff --git a/codex-rs/hooks/src/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs index cc00e31f4905..e6e3c8103251 100644 --- a/codex-rs/hooks/src/events/permission_request.rs +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -30,6 +30,7 @@ use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; +use codex_utils_path_uri::PathUri; use serde_json::Value; #[derive(Debug, Clone)] @@ -38,6 +39,9 @@ pub struct PermissionRequestRequest { pub turn_id: String, pub subagent: Option, pub cwd: PathBuf, + /// Local approval action cwd for hook stdin. Remote commands leave this unset + /// to retain the host cwd. The hook process still runs in `cwd`. + pub tool_cwd: Option, pub transcript_path: Option, pub model: String, pub permission_mode: String, @@ -176,7 +180,7 @@ fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestC agent_id: subagent.agent_id, agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), - cwd: request.cwd.display().to_string(), + cwd: common::command_input_cwd(request.cwd.as_ref(), request.tool_cwd.as_ref()), hook_event_name: "PermissionRequest".to_string(), model: request.model.clone(), permission_mode: request.permission_mode.clone(), @@ -304,6 +308,37 @@ mod tests { use super::PermissionRequestDecision; use super::resolve_permission_request_decision; + #[test] + fn command_input_uses_approval_cwd_path_convention() { + let mut request = super::PermissionRequestRequest { + session_id: codex_protocol::ThreadId::new(), + turn_id: "turn-1".into(), + subagent: None, + cwd: codex_utils_absolute_path::test_support::test_path_buf("/local"), + tool_cwd: None, + transcript_path: None, + model: "test".into(), + permission_mode: "default".into(), + tool_name: "Bash".into(), + matcher_aliases: Vec::new(), + run_id_suffix: "call-cwd".into(), + tool_input: serde_json::json!({"command": "pwd"}), + }; + let original: serde_json::Value = + serde_json::to_value(super::build_command_input(&request)).unwrap(); + for (uri, expected) in [ + ("file:///repo-B", "/repo-B"), + ("file:///C:/repo-B", r"C:\repo-B"), + ] { + request.tool_cwd = Some(codex_utils_path_uri::PathUri::parse(uri).unwrap()); + let actual: serde_json::Value = + serde_json::to_value(super::build_command_input(&request)).unwrap(); + let mut expected_input = original.clone(); + expected_input["cwd"] = serde_json::json!(expected); + assert_eq!(actual, expected_input); + } + } + #[test] fn permission_request_deny_overrides_earlier_allow() { let decisions = [ diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 8af7ba5a2b8f..58bff2fc75c9 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use serde_json::Value; use super::common; @@ -26,6 +27,9 @@ pub struct PostToolUseRequest { pub turn_id: String, pub subagent: Option, pub cwd: AbsolutePathBuf, + /// Original local execution cwd for hook stdin. Remote commands leave this + /// unset to retain the host cwd. The hook process still runs in `cwd`. + pub tool_cwd: Option, pub transcript_path: Option, pub model: String, pub permission_mode: String, @@ -155,7 +159,7 @@ fn command_input(request: &PostToolUseRequest) -> Result Result Option { + None + } + /// Returns the tool input exposed to `PostToolUse` hooks for this output. fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option { None @@ -98,6 +106,10 @@ where (**self).post_tool_use_id(call_id) } + fn post_tool_use_cwd(&self) -> Option { + (**self).post_tool_use_cwd() + } + fn post_tool_use_input(&self, payload: &ToolPayload) -> Option { (**self).post_tool_use_input(payload) } From 85f29b453a0ca77f718f09428e5f79e33d498836 Mon Sep 17 00:00:00 2001 From: Alex Kira Date: Sat, 24 Oct 2026 01:43:04 +0000 Subject: [PATCH 032/287] Register the `untrusted_as_tool_output` feature flag (#57204) ## What changed Add `untrusted_as_tool_output` as an under-development feature, disabled by default, and expose it in the configuration schema. The flag is intended for rendering client-supplied untrusted context as standalone named tool outputs. GitOrigin-RevId: 6fe630c8d380a87edb48e08887215c1ec17447ed --- codex-rs/core/config.schema.json | 6 ++++++ codex-rs/features/src/lib.rs | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0f83fd98aa0a..a61f58b8eaf9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1395,6 +1395,9 @@ "unified_image_budget": { "type": "boolean" }, + "untrusted_as_tool_output": { + "type": "boolean" + }, "use_agent_identity": { "type": "boolean" }, @@ -8000,6 +8003,9 @@ "unified_image_budget": { "type": "boolean" }, + "untrusted_as_tool_output": { + "type": "boolean" + }, "use_agent_identity": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 19cc2c1c7d3d..4a6595122dd3 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -134,6 +134,8 @@ pub enum Feature { MultiEnvCopyFile, /// Send per-content-entry classifications in internal Responses metadata. ContentItemKinds, + /// Render client-supplied untrusted context as standalone named tool outputs. + UntrustedAsToolOutput, /// Record model-attempted tool calls in internal Responses metadata. ExecutedToolCallMetadata, /// Enable JavaScript code mode backed by the standalone host process. @@ -1202,6 +1204,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::UntrustedAsToolOutput, + key: "untrusted_as_tool_output", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ExecutedToolCallMetadata, key: "executed_tool_call_metadata", From e1c87d1c2d623a69007c8d0bedf497d379371e7c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 24 Oct 2026 17:47:13 +0000 Subject: [PATCH 033/287] Ignore the crypto-bigint timing advisory in dependency checks (#57206) Add `RUSTSEC-2026-0387` to the `cargo audit` and `cargo deny` ignore lists for `crypto-bigint` 0.5.5, pulled in through `elliptic-curve` 0.13.8. Document the affected dependency paths and the potential timing leakage during private-key or session-secret operations. Remove the exceptions when those paths use `crypto-bigint` 0.7.6 or later. GitOrigin-RevId: 7ff42c9b127900ddf772bf1cc88947f6e7dfcd10 --- codex-rs/.cargo/audit.toml | 1 + codex-rs/deny.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/codex-rs/.cargo/audit.toml b/codex-rs/.cargo/audit.toml index a1e347895cab..743010925f11 100644 --- a/codex-rs/.cargo/audit.toml +++ b/codex-rs/.cargo/audit.toml @@ -17,4 +17,5 @@ ignore = [ "RUSTSEC-2026-0372", # zvariant_derive 4.2.0 via zbus 4.4.0 in Linux TUI and keyring/secret-service; remove when those paths migrate off the retired standalone crates # Reviewed 2026-10-20. "RUSTSEC-2026-0374", # basic-toml 0.1.10 via i18n-config/i18n-embed-fl under age 0.12.1 in codex-secrets; no patched release; remove when age's localization dependencies migrate off basic-toml + "RUSTSEC-2026-0387", # crypto-bigint 0.5.5 via elliptic-curve 0.13.8 is used by age, AWS login, user verification, and WebRTC; timing leakage can affect private-key or session-secret operations; remove when these dependency paths use crypto-bigint >=0.7.6 ] diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 1abe4ad02c77..57c33b36661e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -89,6 +89,7 @@ ignore = [ { id = "RUSTSEC-2026-0372", reason = "zvariant_derive 4.2.0 is unmaintained; pulled in via zbus 4.4.0 by Linux TUI accessibility integration and keyring/secret-service; remove when those paths migrate off the retired standalone crates" }, # Reviewed 2026-10-20. { id = "RUSTSEC-2026-0374", reason = "basic-toml 0.1.10 is unmaintained; pulled in via i18n-config/i18n-embed-fl under age 0.12.1 used by codex-secrets; no patched release; remove when age's localization dependencies migrate off basic-toml" }, + { id = "RUSTSEC-2026-0387", reason = "crypto-bigint 0.5.5 via elliptic-curve 0.13.8 is used by age, AWS login, user verification, and WebRTC; timing leakage can affect private-key or session-secret operations; remove when these dependency paths use crypto-bigint >=0.7.6" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. From 7c997fe2de60e6e4ac95cfc19edf6a5d430f09d6 Mon Sep 17 00:00:00 2001 From: taohong-oai Date: Sat, 24 Oct 2026 18:26:07 +0000 Subject: [PATCH 034/287] Attribute approval waits to tool critical-path timing (#57207) ## Why Automatic reviews and user prompts previously invalidated critical-path captures even when their owning tool invocation was known. ## What changed Record automatic review time as `auto_review` and waits for approvals, permission requests, and user input as `user_wait` when `CriticalPathTiming` is enabled. Bind timing to the owning invocation before asynchronous work begins, and use the triggering tool's identity for network approvals. Continue invalidating captures when a wait cannot be attributed. ## Testing Add coverage for cancellation, reused call IDs, network ownership, and disabled capture. Update the automatic review integration test to verify attributed review duration. GitOrigin-RevId: 0a1f5feebd0c4f5ed67dc058e832ec5f8818db02 --- codex-rs/core/src/guardian/decision.rs | 38 +++- .../src/guardian/decision_timing_tests.rs | 195 ++++++++++++++++-- .../src/session/elicitation_holders_tests.rs | 125 ++++++++--- codex-rs/core/src/session/mod.rs | 47 ++++- codex-rs/core/src/tools/approvals.rs | 4 + codex-rs/core/tests/suite/auto_review.rs | 16 +- 6 files changed, 359 insertions(+), 66 deletions(-) diff --git a/codex-rs/core/src/guardian/decision.rs b/codex-rs/core/src/guardian/decision.rs index 3e35b4ad5943..ff5a25d90df7 100644 --- a/codex-rs/core/src/guardian/decision.rs +++ b/codex-rs/core/src/guardian/decision.rs @@ -52,17 +52,45 @@ pub(crate) fn decide_approval( ) -> futures::future::BoxFuture<'static, Option> { let context: GuardianReviewContext = context.into(); let request: ReviewAction = request.into(); - // Invalidate before scheduling: cancellation may finalize the turn before - // the review future is first polled. Replace this boundary with attribution. - if context + // Bind the invocation before scheduling. Async review cleanup can outlive + // the decision, but must not extend the time blocking this tool. + let review_timing = if context .turn() .config .features .enabled(codex_features::Feature::CriticalPathTiming) { - context.turn().turn_timing_state.invalidate_critical_path(); - } + // Network display identity differs from its owning tool invocation. Keep + // timing correlation separate from the reviewer's existing provenance. + let timing_call_id = match &request.request { + Ok(GuardianApprovalRequest::NetworkAccess { trigger, .. }) => { + trigger.as_ref().map(|trigger| trigger.call_id.as_str()) + } + Ok( + GuardianApprovalRequest::ExecCommand { .. } + | GuardianApprovalRequest::WriteStdin { .. } + | GuardianApprovalRequest::ApplyPatch { .. } + | GuardianApprovalRequest::McpToolCall { .. } + | GuardianApprovalRequest::RequestPermissions { .. }, + ) + | Err(_) => request.tool_call_id.as_deref(), + }; + let timing = timing_call_id.and_then(|call_id| { + context + .turn() + .turn_timing_state + .direct_tool_timings + .start_review_for_turn(call_id, &context.turn().sub_id) + }); + if timing.is_none() { + context.turn().turn_timing_state.invalidate_critical_path(); + } + timing + } else { + None + }; Box::pin(async move { + let _review_timing = review_timing; let (_, history_reset) = session.history_reset().await; let cancellation = match options.external_cancel.take() { Some(external) => crate::exec::cancel_when_either(external, history_reset.clone()), diff --git a/codex-rs/core/src/guardian/decision_timing_tests.rs b/codex-rs/core/src/guardian/decision_timing_tests.rs index 712b595d8bed..8c2a3ec1f469 100644 --- a/codex-rs/core/src/guardian/decision_timing_tests.rs +++ b/codex-rs/core/src/guardian/decision_timing_tests.rs @@ -1,15 +1,32 @@ #![cfg(test)] -//! Keeps uninstrumented review work out of complete critical-path captures. +//! Verifies review timing binds to its invocation before scheduling and respects the capture gate. +use codex_direct_tool_timing::ToolBatchTiming; +use codex_utils_critical_path::CriticalPath; +use codex_utils_critical_path::PathDurations; +use core_test_support::PathBufExt; +use core_test_support::test_path_buf; use pretty_assertions::assert_eq; use std::sync::Arc; +use std::time::Duration; use std::time::Instant; -#[test_case::test_case(true; "capture_enabled")] -#[test_case::test_case(false; "capture_disabled")] +#[test_case::test_case(false, false, true; "drop_before_poll")] +#[test_case::test_case(false, true, true; "cancelled_decision_returns")] +#[test_case::test_case(true, false, true; "uncorrelated_network")] +#[test_case::test_case(false, false, false; "runtime_metrics_does_not_start_review_timing")] +#[test_case::test_case(true, false, false; "runtime_metrics_does_not_invalidate_review_timing")] #[tokio::test] -async fn uncaptured_review_invalidates_before_polling(critical_path_timing: bool) { +async fn decision_binds_before_scheduling_and_does_not_follow_reused_ids( + network: bool, + await_decision: bool, + critical_path_timing: bool, +) { let (session, mut turn) = crate::session::tests::make_session_and_context().await; + Arc::make_mut(&mut turn.config) + .features + .enable(codex_features::Feature::RuntimeMetrics) + .unwrap(); Arc::make_mut(&mut turn.config) .features .set_enabled( @@ -17,29 +34,54 @@ async fn uncaptured_review_invalidates_before_polling(critical_path_timing: bool critical_path_timing, ) .unwrap(); + let session = Arc::new(session); let turn = Arc::new(turn); turn.turn_timing_state.enable_critical_path(); turn.turn_timing_state .mark_turn_started(Instant::now()) .await; - assert!( - turn.turn_timing_state - .snapshot_path(Instant::now()) - .is_some() + let timings = &turn.turn_timing_state.direct_tool_timings; + let batch = ToolBatchTiming::default(); + let original_started_at = Instant::now(); + let original = timings.start_turn_direct_call( + "call", + &turn.sub_id, + batch.clone(), + original_started_at, + Some(CriticalPath::default()), ); - let cancellation = tokio_util::sync::CancellationToken::new(); - cancellation.cancel(); - let decision = crate::guardian::decide_approval( - Arc::new(session), - &turn, - "review".to_string(), + let request = if network { + crate::guardian::GuardianApprovalRequest::NetworkAccess { + id: "network-review".to_string(), + turn_id: turn.sub_id.clone(), + environment_id: "local".to_string(), + target: "example.com:443".to_string(), + host: "example.com".to_string(), + protocol: codex_protocol::approvals::NetworkApprovalProtocol::Https, + port: 443, + trigger: None, + } + } else { crate::guardian::GuardianApprovalRequest::ApplyPatch { id: "call".to_string(), environment_id: "local".to_string(), cwd: codex_utils_path_uri::PathUri::parse("file:///tmp").unwrap(), files: Vec::new(), patch: String::new(), - }, + } + }; + assert!( + timings + .start_review_for_turn("call", "another-turn") + .is_none() + ); + let cancellation = tokio_util::sync::CancellationToken::new(); + cancellation.cancel(); + let future = crate::guardian::decide_approval( + Arc::clone(&session), + &turn, + "review".to_string(), + request, crate::tools::sandboxing::ApprovalRequestReasons::default(), crate::guardian::GuardianReviewOptions { require_guardian: true, @@ -49,15 +91,130 @@ async fn uncaptured_review_invalidates_before_polling(critical_path_timing: bool require_synchronous_review: false, }, ); - // No polling or worker scheduling is needed to protect a finalizing turn. assert_eq!( turn.turn_timing_state .snapshot_path(Instant::now()) .is_none(), - critical_path_timing + network && critical_path_timing + ); + if await_decision { + assert_eq!( + future.await, + Some(codex_protocol::protocol::ReviewDecision::Abort) + ); + let completed = original.finish().unwrap(); + let durations = completed + .path + .unwrap() + .durations(original_started_at, completed.ended_at); + assert!(durations.auto_review > Duration::ZERO); + return; + } + // Finishing before polling observes the review started synchronously by decide_approval. + let completed = original.finish().unwrap(); + let durations = completed + .path + .unwrap() + .durations(original_started_at, completed.ended_at); + assert_eq!( + durations.auto_review > Duration::ZERO, + !network && critical_path_timing + ); + drop(original); + let replacement_started_at = Instant::now(); + let replacement = timings.start_turn_direct_call( + "call", + &turn.sub_id, + batch, + replacement_started_at, + Some(CriticalPath::default()), ); + drop(future); + let completed = replacement.finish().unwrap(); assert_eq!( - decision.await, - Some(codex_protocol::protocol::ReviewDecision::Abort) + completed + .path + .unwrap() + .durations(replacement_started_at, completed.ended_at), + PathDurations { + tools: completed.ended_at.duration_since(replacement_started_at), + ..PathDurations::default() + } + ); +} + +#[test_case::test_case(true; "owned")] +#[test_case::test_case(false; "ownerless")] +#[tokio::test] +async fn network_review_timing_uses_trigger_owner(owned: bool) { + let (session, mut turn, _rx) = crate::session::tests::make_session_and_context_with_rx().await; + Arc::make_mut(&mut Arc::get_mut(&mut turn).expect("unique test turn").config) + .features + .enable(codex_features::Feature::CriticalPathTiming) + .expect("critical-path capture is configurable"); + let timing = &turn.turn_timing_state; + let start = Instant::now(); + timing.mark_turn_started(start).await; + timing.enable_critical_path(); + let batch = ToolBatchTiming::default(); + let work = turn + .turn_timing_state + .direct_tool_timings + .start_turn_direct_call( + "network-tool", + &turn.sub_id, + batch.clone(), + start, + timing.snapshot_path(start), + ); + let blocking = timing.begin_tool_blocking(Some(batch)); + let request = super::GuardianApprovalRequest::NetworkAccess { + id: "network-review".to_string(), + turn_id: turn.sub_id.clone(), + environment_id: "local".to_string(), + target: "https://example.com:443".to_string(), + host: "example.com".to_string(), + protocol: codex_protocol::approvals::NetworkApprovalProtocol::Https, + port: 443, + trigger: owned.then(|| super::GuardianNetworkAccessTrigger { + call_id: "network-tool".to_string(), + tool_name: "shell".to_string(), + command: vec!["curl".to_string()], + cwd: test_path_buf("/repo").abs().into(), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: None, + tty: None, + }), + }; + // Binding happens before scheduling, so this also covers cancellation before polling. + let decision = super::decide_approval( + Arc::clone(&session), + &turn, + "network-review".to_string(), + request, + crate::tools::sandboxing::ApprovalRequestReasons { + approval: None, + retry: None, + }, + super::GuardianReviewOptions { + require_guardian: true, + plugin_attribution_override: None, + approval_request_source: codex_analytics::GuardianApprovalRequestSource::MainTurn, + external_cancel: None, + require_synchronous_review: false, + }, ); + drop(decision); + drop(work); + drop(blocking); + let end = Instant::now(); + let path = timing.snapshot_path(end); + if owned { + let totals = path.expect("owned network capture").durations(start, end); + assert!(totals.auto_review > Duration::ZERO); + assert_eq!(totals.unknown, Duration::ZERO); + } else { + assert_eq!(path, None); + } } diff --git a/codex-rs/core/src/session/elicitation_holders_tests.rs b/codex-rs/core/src/session/elicitation_holders_tests.rs index 2d86b74c623d..71378055119b 100644 --- a/codex-rs/core/src/session/elicitation_holders_tests.rs +++ b/codex-rs/core/src/session/elicitation_holders_tests.rs @@ -1,6 +1,10 @@ +use codex_direct_tool_timing::ToolBatchTiming; +use codex_direct_tool_timing::ToolTimingGuard; use codex_features::Feature; +use codex_utils_critical_path::CriticalPath; use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use std::time::Instant; use codex_core_plugins::PluginCommandAttribution; @@ -20,25 +24,32 @@ use super::tests::make_session_and_context_with_rx; use crate::session::step_context::StepContext; use crate::state::ActiveTurn; -async fn start_timing(turn: &mut Arc, enabled: bool) { +fn start_timing( + turn: &mut Arc, + enabled: bool, +) -> (Arc, Instant) { Arc::make_mut(&mut Arc::get_mut(turn).expect("unique test turn").config) .features .set_enabled(Feature::CriticalPathTiming, enabled) .unwrap(); - // Populate state independently to verify disabled capture does not invalidate it. - turn.turn_timing_state.enable_critical_path(); - turn.turn_timing_state - .mark_turn_started(Instant::now()) - .await; + let start = Instant::now(); + let owner = turn + .turn_timing_state + .direct_tool_timings + .start_turn_direct_call( + "call-1", + &turn.sub_id, + ToolBatchTiming::default(), + start, + Some(CriticalPath::default()), + ); + (owner, start) } -fn assert_uncaptured_wait(turn: &super::turn_context::TurnContext, enabled: bool) { - assert_eq!( - turn.turn_timing_state - .snapshot_path(Instant::now()) - .is_none(), - enabled - ); +fn assert_user_wait(owner: &ToolTimingGuard, start: Instant, enabled: bool) { + let completed = owner.finish().unwrap(); + let totals = completed.path.unwrap().durations(start, completed.ended_at); + assert_eq!(totals.user_wait > Duration::ZERO, enabled); } async fn wait_until_held(pause_state: &mut watch::Receiver) { @@ -55,12 +66,13 @@ async fn wait_until_released(pause_state: &mut watch::Receiver) { .expect("elicitation service should remain available"); } -#[test_case::test_case(true; "capture_on")] -#[test_case::test_case(false; "capture_off")] +#[test_case::test_case(true, "call-1"; "capture_on")] +#[test_case::test_case(false, "call-1"; "capture_off")] +#[test_case::test_case(true, "network-prompt"; "distinct_network_prompt_identity")] #[tokio::test] -async fn command_approval_holds_an_elicitation_until_response(capture: bool) { +async fn command_approval_holds_an_elicitation_until_response(capture: bool, prompt_id: &str) { let (session, mut turn_context, events) = make_session_and_context_with_rx().await; - start_timing(&mut turn_context, capture).await; + let (timing, timing_start) = start_timing(&mut turn_context, capture); *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); #[allow(deprecated)] @@ -71,6 +83,7 @@ async fn command_approval_holds_an_elicitation_until_response(capture: bool) { }; let request = tokio::spawn({ + let prompt_id = prompt_id.to_owned(); let session = session.clone(); let turn_context = turn_context.clone(); async move { @@ -80,7 +93,8 @@ async fn command_approval_holds_an_elicitation_until_response(capture: bool) { ExecApprovalKind::Command, crate::guardian::GuardianReviewContext::from(turn_context.clone()) .model_context(), - "call-1".to_string(), + prompt_id, + Some("call-1"), /*approval_id*/ None, /*environment_id*/ None, vec!["echo".to_string()], @@ -104,17 +118,19 @@ async fn command_approval_holds_an_elicitation_until_response(capture: bool) { assert_eq!(event.script_path.as_deref(), Some("scripts/run.py")); wait_until_held(&mut pause_state).await; session - .notify_approval("call-1", ReviewDecision::Approved) + .notify_approval(prompt_id, ReviewDecision::Approved) .await; request.await.expect("approval task"); wait_until_released(&mut pause_state).await; - assert_uncaptured_wait(&turn_context, capture); + assert_user_wait(&timing, timing_start, capture); } +#[test_case::test_case(true; "capture_on")] +#[test_case::test_case(false; "capture_off")] #[tokio::test] -async fn patch_approval_holds_an_elicitation_until_response() { +async fn patch_approval_holds_an_elicitation_until_response(capture: bool) { let (session, mut turn_context, events) = make_session_and_context_with_rx().await; - start_timing(&mut turn_context, /*enabled*/ true).await; + let (timing, timing_start) = start_timing(&mut turn_context, capture); *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); @@ -141,13 +157,15 @@ async fn patch_approval_holds_an_elicitation_until_response() { .await; request.await.expect("approval task"); wait_until_released(&mut pause_state).await; - assert_uncaptured_wait(&turn_context, /*enabled*/ true); + assert_user_wait(&timing, timing_start, capture); } +#[test_case::test_case(true; "capture_on")] +#[test_case::test_case(false; "capture_off")] #[tokio::test] -async fn permission_request_holds_an_elicitation_until_response() { +async fn permission_request_holds_an_elicitation_until_response(capture: bool) { let (session, mut turn_context, events) = make_session_and_context_with_rx().await; - start_timing(&mut turn_context, /*enabled*/ true).await; + let (timing, timing_start) = start_timing(&mut turn_context, capture); *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); @@ -190,16 +208,21 @@ async fn permission_request_holds_an_elicitation_until_response() { .await; request.await.expect("permission request task"); wait_until_released(&mut pause_state).await; - assert_uncaptured_wait(&turn_context, /*enabled*/ true); + assert_user_wait(&timing, timing_start, capture); } -#[test_case::test_case(true, false; "blocking")] -#[test_case::test_case(false, false; "nonblocking_ui_label")] -#[test_case::test_case(true, true; "cancelled")] +#[test_case::test_case(true, true, false; "blocking")] +#[test_case::test_case(true, false, false; "nonblocking_ui_label")] +#[test_case::test_case(true, true, true; "cancelled")] +#[test_case::test_case(false, true, false; "capture_off")] #[tokio::test] -async fn request_user_input_holds_an_elicitation_until_response(blocking: bool, cancel: bool) { +async fn request_user_input_holds_an_elicitation_until_response( + capture: bool, + blocking: bool, + cancel: bool, +) { let (session, mut turn_context, events) = make_session_and_context_with_rx().await; - start_timing(&mut turn_context, /*enabled*/ true).await; + let (timing, timing_start) = start_timing(&mut turn_context, capture); *session.active_turn.lock().await = Some(ActiveTurn::default()); let mut pause_state = session.subscribe_elicitation_pause_state(); @@ -228,7 +251,7 @@ async fn request_user_input_holds_an_elicitation_until_response(blocking: bool, request.abort(); assert!(matches!(request.await, Err(error) if error.is_cancelled())); wait_until_released(&mut pause_state).await; - assert_uncaptured_wait(&turn_context, /*enabled*/ true); + assert_user_wait(&timing, timing_start, capture); return; } let response = RequestUserInputResponse { @@ -240,5 +263,41 @@ async fn request_user_input_holds_an_elicitation_until_response(blocking: bool, request.await.expect("request user input task"); wait_until_released(&mut pause_state).await; - assert_uncaptured_wait(&turn_context, /*enabled*/ true); + assert_user_wait(&timing, timing_start, capture); +} + +#[test_case::test_case(true; "enabled_unowned_wait_is_incomplete")] +#[test_case::test_case(false; "disabled_unowned_wait_is_unchanged")] +#[tokio::test] +async fn unowned_human_wait_does_not_claim_a_complete_path(capture: bool) { + let (session, mut turn_context, _rx) = make_session_and_context_with_rx().await; + let (_tool_timing, _) = start_timing(&mut turn_context, capture); + turn_context.turn_timing_state.enable_critical_path(); + turn_context + .turn_timing_state + .mark_turn_started(Instant::now()) + .await; + assert!( + turn_context + .turn_timing_state + .snapshot_path(Instant::now()) + .is_some() + ); + assert!( + session + .user_wait_timing_owner(&turn_context, Some("unknown-call")) + .is_none() + ); + assert!( + session + .start_user_wait_timing(&turn_context, /*owner*/ None) + .is_none() + ); + assert_eq!( + turn_context + .turn_timing_state + .snapshot_path(Instant::now()) + .is_none(), + capture + ); } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1e70c9a7e458..6aab70c2ca7d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2998,15 +2998,42 @@ impl Session { .await; } - fn invalidate_uncaptured_user_wait(&self, turn_context: &TurnContext) { - // Replace this boundary with owner-scoped timing when user waits are connected. - if turn_context + fn user_wait_timing_owner( + &self, + turn_context: &TurnContext, + call_id: Option<&str>, + ) -> Option> { + if !turn_context .config .features .enabled(Feature::CriticalPathTiming) + { + return None; + } + // Retain the invocation identity before request preparation can suspend. + call_id.and_then(|call_id| { + turn_context + .turn_timing_state + .direct_tool_timings + .owner_for_turn(call_id, &turn_context.sub_id) + }) + } + + fn start_user_wait_timing( + &self, + turn_context: &TurnContext, + owner: Option<&codex_direct_tool_timing::ToolTimingGuard>, + ) -> Option { + let timing = owner.and_then(codex_direct_tool_timing::ToolTimingGuard::start_user_wait); + if timing.is_none() + && turn_context + .config + .features + .enabled(Feature::CriticalPathTiming) { turn_context.turn_timing_state.invalidate_critical_path(); } + timing } /// Emit an exec approval request event and await the user's decision. @@ -3030,6 +3057,7 @@ impl Session { kind: ExecApprovalKind, model_context: ModelInvocationContext, call_id: String, + timing_call_id: Option<&str>, approval_id: Option, environment_id: Option, command: Vec, @@ -3041,6 +3069,7 @@ impl Session { available_decisions: Option>, plugin_attribution_override: Option, ) -> ReviewDecision { + let user_wait_owner = self.user_wait_timing_owner(turn_context, timing_call_id); let _elicitation = self.services.elicitations.register(); // command-level approvals use `call_id`. // `approval_id` identifies subcommand callbacks and stdin writes. @@ -3111,7 +3140,7 @@ impl Session { available_decisions: Some(available_decisions), parsed_cmd, }); - self.invalidate_uncaptured_user_wait(turn_context); + let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref()); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or(ReviewDecision::Abort) } @@ -3128,6 +3157,7 @@ impl Session { reason: Option, grant_root: Option, ) -> ReviewDecision { + let user_wait_owner = self.user_wait_timing_owner(turn_context, Some(&call_id)); let _elicitation = self.services.elicitations.register(); // Add the tx_approve callback to the map before sending the request. let (tx_approve, rx_approve) = oneshot::channel(); @@ -3154,7 +3184,7 @@ impl Session { reason, grant_root, }); - self.invalidate_uncaptured_user_wait(turn_context); + let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref()); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or(ReviewDecision::Abort) } @@ -3172,6 +3202,7 @@ impl Session { cancellation_token: CancellationToken, ) -> Option { let turn_context = &step_context.turn; + let user_wait_owner = self.user_wait_timing_owner(turn_context, Some(&call_id)); let approval_policy = step_context.settings.approval_policy(); let Some(environment) = step_context .environments @@ -3320,11 +3351,12 @@ impl Session { permissions: requested_permissions, cwd: Some(cwd.clone().into()), }); - self.invalidate_uncaptured_user_wait(turn_context); + let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref()); self.send_event(turn_context.as_ref(), event).await; tokio::select! { biased; _ = cancellation_token.cancelled() => { + drop(_user_wait); let mut active = self.active_turn.lock().await; if let Some(at) = active.as_mut() { let mut ts = at.turn_state.lock().await; @@ -3346,6 +3378,7 @@ impl Session { call_id: String, args: RequestUserInputArgs, ) -> Option { + let user_wait_owner = self.user_wait_timing_owner(turn_context, Some(&call_id)); let _elicitation = self.services.elicitations.register(); let sub_id = turn_context.sub_id.clone(); let (tx_response, rx_response) = oneshot::channel(); @@ -3374,7 +3407,7 @@ impl Session { turn_context .turn_metadata_state .mark_user_input_requested_during_turn(); - self.invalidate_uncaptured_user_wait(turn_context); + let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref()); self.send_event(turn_context, event).await; rx_response.await.ok() } diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 22823beb6a08..e7e846c439be 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -752,6 +752,7 @@ impl Session { ExecApprovalKind::Command, ctx.review_context.model_context(), ctx.call_id.clone(), + Some(&ctx.call_id), /*approval_id*/ None, Some(environment_id.clone()), command.clone(), @@ -782,6 +783,7 @@ impl Session { ExecApprovalKind::WriteStdin, ctx.review_context.model_context(), id.clone(), + Some(&ctx.call_id), Some(approval_id.clone()), Some(environment_id.clone()), vec![ @@ -852,6 +854,7 @@ impl Session { environment_id, command, cwd, + trigger, .. } => { self.request_command_approval( @@ -859,6 +862,7 @@ impl Session { ExecApprovalKind::Command, ctx.review_context.model_context(), ctx.call_id.clone(), + trigger.as_ref().map(|trigger| trigger.call_id.as_str()), /*approval_id*/ None, Some(environment_id.clone()), command.clone(), diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index e53d538054d4..bd970ce7122d 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -160,7 +160,10 @@ async fn approval_review_contributor_skips_existing_guardian_model_call() -> Res let observe = codex_core::test_support::observe_turn_timing(&test.codex) .await .expect("active turn"); - let now = std::time::Instant::now(); + let review_start = std::time::Instant::now(); + tokio::task::yield_now().await; + let review_end = std::time::Instant::now(); + assert!(review_end > review_start); reviewer.release.add_permits(/* n */ 1); loop { @@ -176,7 +179,16 @@ async fn approval_review_contributor_skips_existing_guardian_model_call() -> Res } } - assert_eq!(observe(now, now), None); + assert_eq!( + observe(review_start, review_end), + Some([ + Duration::ZERO, + review_end.duration_since(review_start), + Duration::ZERO, + Duration::ZERO, + Duration::ZERO, + ]) + ); let requests = responses.requests(); assert_eq!(requests.len(), 2); assert!( From 57b4c60463675209efefdb0fbe91c62e5dc5a819 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 24 Oct 2026 18:47:29 +0000 Subject: [PATCH 035/287] Propagate installer download failures in standalone Unix updates (#57208) ## Why The standalone Unix update pipeline could return success when `curl` failed because the shell consuming its output exited successfully. ## What changed Download the installer into a shell variable and evaluate it only after a successful download, preserving `CODEX_NON_INTERACTIVE=1`. Update the displayed update command to match. ## Testing Add a Unix regression test that forces an installer download failure and asserts a nonzero exit status. Update the update-available snapshot for the new command. GitOrigin-RevId: cf6ce0dae483203e3609bb35f35eb04bb3096c28 --- ...pdate_available_history_cell_snapshot.snap | 15 +++++---- codex-rs/tui/src/update_action.rs | 33 +++++++++++-------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap index c8f352dbb49c..8f1548cf880c 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap @@ -2,10 +2,11 @@ source: tui/src/history_cell/tests.rs expression: rendered --- -╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ✨ Update available! 0.0.0 -> 9.9.9 │ -│ Run sh -c 'curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh' to update. │ -│ │ -│ See full release notes: │ -│ https://github.com/openai/codex/releases/latest │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run sh -c 's=$(curl -fsSL https://chatgpt.com/codex/install.sh) && CODEX_NON_INTERACTIVE=1 eval "$s"' to │ +│ update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 860d0bd10203..7b3433e99251 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -20,7 +20,7 @@ pub enum UpdateAction { PnpmGlobalLatest, /// Update via `brew upgrade codex`. BrewUpgrade, - /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh`. + /// Update via the standalone Unix installer. StandaloneUnix, /// Update via `$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex`. StandaloneWindows, @@ -56,7 +56,7 @@ impl UpdateAction { "sh", &[ "-c", - "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh", + r#"s=$(curl -fsSL https://chatgpt.com/codex/install.sh) && CODEX_NON_INTERACTIVE=1 eval "$s""#, ], ), UpdateAction::StandaloneWindows => ( @@ -156,17 +156,7 @@ mod tests { } #[test] - fn standalone_update_commands_rerun_latest_installer() { - assert_eq!( - UpdateAction::StandaloneUnix.command_args(), - ( - "sh", - &[ - "-c", - "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh" - ][..], - ) - ); + fn standalone_windows_update_command_reruns_latest_installer() { assert_eq!( UpdateAction::StandaloneWindows.command_args(), ( @@ -180,6 +170,23 @@ mod tests { ) ); } + + #[cfg(unix)] + #[test] + fn standalone_unix_update_fails_when_installer_download_fails() { + let (command, args) = UpdateAction::StandaloneUnix.command_args(); + let offline_command = args[1].replace("https://chatgpt.com", "file:///dev/null"); + assert_ne!(offline_command, args[1], "installer URL must be replaced"); + let output = std::process::Command::new(command) + .args([args[0], &offline_command]) + .env_clear() + .env("HOME", "/dev/null") + .env("PATH", "/usr/bin:/bin") + .output() + .expect("run standalone Unix update command"); + + assert!(!output.status.success(), "{output:?}"); + } } /// Package source explicitly selected by the user in the daemon menu. From 33b16a9306dbf8c99fc44addd3ed9ce8883a7590 Mon Sep 17 00:00:00 2001 From: taohong-oai Date: Sat, 24 Oct 2026 19:12:14 +0000 Subject: [PATCH 036/287] Report turn critical-path timing in analytics (#57209) ## What changed Include critical-path capture status and duration totals in `codex_turn_event`, with categories for inference, auto review, user wait, tools, overhead, and unknown time. Count sampling and compaction as inference. Distinguish `disabled`, `incomplete`, and `complete` capture. Emit null durations for disabled or incomplete capture so missing measurements are not reported as zero. Freeze the completion timestamp so repeated terminal notifications preserve the original measurement window. ## Testing Add core and analytics tests for capture status, duration reporting, rounding, and repeated completion. Add app-server integration coverage for completed and interrupted turns with default, enabled, and disabled capture. GitOrigin-RevId: c6c867120ee563ac48584c2f4b12df23acbabe06 --- codex-rs/analytics/src/events.rs | 2 + codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/lib.rs | 3 + codex-rs/analytics/src/reducer.rs | 2 + .../src/tests/suite/turns/events_tests.rs | 113 +++++++++++ codex-rs/analytics/src/tests/support.rs | 1 + codex-rs/analytics/src/turn_critical_path.rs | 59 ++++++ .../suite/v2/critical_path_analytics_tests.rs | 179 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 2 + codex-rs/core/src/turn_timing.rs | 18 +- codex-rs/core/src/turn_timing/report.rs | 26 +++ codex-rs/core/src/turn_timing_tests.rs | 76 +++++++- 12 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 codex-rs/analytics/src/turn_critical_path.rs create mode 100644 codex-rs/app-server/tests/suite/v2/critical_path_analytics_tests.rs create mode 100644 codex-rs/core/src/turn_timing/report.rs diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 79dbf285362b..9bddf2231baf 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1110,6 +1110,8 @@ pub(crate) struct CodexGoalEventRequest { #[derive(Serialize)] pub(crate) struct CodexTurnEventParams { + #[serde(flatten)] + pub(crate) critical_path: crate::turn_critical_path::CriticalPathEventParams, pub(crate) thread_id: String, pub(crate) session_id: String, pub(crate) turn_id: String, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 9189babd9f1b..68d055c80fca 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -246,6 +246,7 @@ pub struct TurnTokenUsageFact { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct TurnProfile { + pub critical_path: crate::TurnCriticalPath, pub before_first_sampling_ms: u64, pub sampling_ms: u64, pub compaction_ms: u64, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 6160dd704a0a..15a1d4fd0bbf 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -8,6 +8,7 @@ mod guardian_v2; mod product_attribution; mod reducer; mod thread_hint; +mod turn_critical_path; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -82,6 +83,8 @@ pub use guardian_v2::GuardianV2EventKind; pub use product_attribution::ThreadProductUpdate; pub use thread_hint::ThreadHintStatus; pub use thread_hint::ThreadHintStatusEvent; +pub use turn_critical_path::TurnCriticalPath; +pub use turn_critical_path::TurnCriticalPathDurations; #[cfg(test)] mod tests; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7d0bf4058ee3..d0707e36c34d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -3958,6 +3958,7 @@ fn codex_turn_event_params( is_first_turn, } = resolved_config; let TurnProfile { + critical_path, before_first_sampling_ms, sampling_ms, compaction_ms, @@ -3971,6 +3972,7 @@ fn codex_turn_event_params( let token_usage = turn_state.token_usage.clone(); let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { + critical_path: critical_path.into(), thread_id, session_id: thread_metadata.session_id.clone(), turn_id, diff --git a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs index 06896368adb2..5a86e9a05828 100644 --- a/codex-rs/analytics/src/tests/suite/turns/events_tests.rs +++ b/codex-rs/analytics/src/tests/suite/turns/events_tests.rs @@ -102,6 +102,7 @@ fn turn_event_serializes_expected_shape() { let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { event_type: "codex_turn_event", event_params: crate::events::CodexTurnEventParams { + critical_path: crate::TurnCriticalPath::Disabled.into(), thread_id: "thread-2".to_string(), session_id: "session-thread-2".to_string(), turn_id: "turn-2".to_string(), @@ -187,6 +188,14 @@ fn turn_event_serializes_expected_shape() { r#"{ "event_type": "codex_turn_event", "event_params": { + "critical_path_status": "disabled", + "critical_path_duration_ms": null, + "critical_path_inference_ms": null, + "critical_path_auto_review_ms": null, + "critical_path_user_wait_ms": null, + "critical_path_tools_ms": null, + "critical_path_overhead_ms": null, + "critical_path_unknown_ms": null, "thread_id": "thread-2", "session_id": "session-thread-2", "turn_id": "turn-2", @@ -958,3 +967,107 @@ async fn turn_completed_without_started_notification_emits_null_started_at() { ); assert_eq!(payload["event_params"]["total_tokens"], json!(null)); } + +#[tokio::test] +async fn critical_path_payload_preserves_capture_status_and_emits_once() { + use crate::TurnCriticalPath; + use crate::TurnCriticalPathDurations; + use crate::TurnProfileFact; + use crate::tests::support::sample_turn_profile; + + for status in [ + AppServerTurnStatus::Completed, + AppServerTurnStatus::Interrupted, + ] { + for (critical_path, expected) in [ + ( + TurnCriticalPath::Disabled, + json!(["disabled", null, null, null, null, null, null, null]), + ), + ( + TurnCriticalPath::Incomplete, + json!(["incomplete", null, null, null, null, null, null, null]), + ), + ( + TurnCriticalPath::Complete(TurnCriticalPathDurations { + duration_ms: 1234, + inference_ms: 700, + auto_review_ms: 200, + user_wait_ms: 100, + tools_ms: 150, + overhead_ms: 80, + unknown_ms: 4, + }), + json!(["complete", 1234, 700, 200, 100, 150, 80, 4]), + ), + ( + TurnCriticalPath::Complete(TurnCriticalPathDurations { + duration_ms: 1234, + inference_ms: 1234, + auto_review_ms: 0, + user_wait_ms: 0, + tools_ms: 0, + overhead_ms: 0, + unknown_ms: 0, + }), + json!(["complete", 1234, 1234, 0, 0, 0, 0, 0]), + ), + ] { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ true, + ) + .await; + let mut profile = sample_turn_profile(); + profile.critical_path = critical_path; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(Box::new( + TurnProfileFact { + turn_id: "turn-2".into(), + profile, + }, + ))), + &mut out, + ) + .await; + for _ in 0..2 { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + status.clone(), + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + } + assert_eq!(out.len(), 1); + let payload = serde_json::to_value(&out[0]).unwrap(); + let params = &payload["event_params"]; + assert_eq!( + json!([ + params["critical_path_status"], + params["critical_path_duration_ms"], + params["critical_path_inference_ms"], + params["critical_path_auto_review_ms"], + params["critical_path_user_wait_ms"], + params["critical_path_tools_ms"], + params["critical_path_overhead_ms"], + params["critical_path_unknown_ms"], + ]), + expected + ); + assert_eq!(params["tool_blocking_ms"], json!(250)); + assert_eq!(params["duration_ms"], json!(1234)); + } + } +} diff --git a/codex-rs/analytics/src/tests/support.rs b/codex-rs/analytics/src/tests/support.rs index 8fdfcfd98a59..c47593d50d9c 100644 --- a/codex-rs/analytics/src/tests/support.rs +++ b/codex-rs/analytics/src/tests/support.rs @@ -374,6 +374,7 @@ pub(super) fn sample_turn_resolved_config( pub(super) fn sample_turn_profile() -> TurnProfile { TurnProfile { + critical_path: crate::TurnCriticalPath::Disabled, before_first_sampling_ms: 100, sampling_ms: 700, compaction_ms: 40, diff --git a/codex-rs/analytics/src/turn_critical_path.rs b/codex-rs/analytics/src/turn_critical_path.rs new file mode 100644 index 000000000000..ef8e4738d807 --- /dev/null +++ b/codex-rs/analytics/src/turn_critical_path.rs @@ -0,0 +1,59 @@ +//! Turn critical-path facts and their nullable analytics fields. +//! Missing capture never becomes a zero-duration measurement. + +use serde::Serialize; + +/// Capture coverage for one finalized turn, independent of its completion status. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub enum TurnCriticalPath { + #[default] + Disabled, + Incomplete, + Complete(TurnCriticalPathDurations), +} + +/// Category totals over the server turn interval, truncated to milliseconds. +/// Sampling and compaction both contribute to inference. Independent truncation +/// can leave the category sum up to five milliseconds below duration_ms. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TurnCriticalPathDurations { + pub duration_ms: u64, + pub inference_ms: u64, + pub auto_review_ms: u64, + pub user_wait_ms: u64, + pub tools_ms: u64, + pub overhead_ms: u64, + pub unknown_ms: u64, +} + +#[derive(Serialize)] +pub(crate) struct CriticalPathEventParams { + critical_path_status: &'static str, + critical_path_duration_ms: Option, + critical_path_inference_ms: Option, + critical_path_auto_review_ms: Option, + critical_path_user_wait_ms: Option, + critical_path_tools_ms: Option, + critical_path_overhead_ms: Option, + critical_path_unknown_ms: Option, +} + +impl From for CriticalPathEventParams { + fn from(path: TurnCriticalPath) -> Self { + let (status, durations) = match path { + TurnCriticalPath::Disabled => ("disabled", None), + TurnCriticalPath::Incomplete => ("incomplete", None), + TurnCriticalPath::Complete(durations) => ("complete", Some(durations)), + }; + Self { + critical_path_status: status, + critical_path_duration_ms: durations.as_ref().map(|value| value.duration_ms), + critical_path_inference_ms: durations.as_ref().map(|value| value.inference_ms), + critical_path_auto_review_ms: durations.as_ref().map(|value| value.auto_review_ms), + critical_path_user_wait_ms: durations.as_ref().map(|value| value.user_wait_ms), + critical_path_tools_ms: durations.as_ref().map(|value| value.tools_ms), + critical_path_overhead_ms: durations.as_ref().map(|value| value.overhead_ms), + critical_path_unknown_ms: durations.as_ref().map(|value| value.unknown_ms), + } + } +} diff --git a/codex-rs/app-server/tests/suite/v2/critical_path_analytics_tests.rs b/codex-rs/app-server/tests/suite/v2/critical_path_analytics_tests.rs new file mode 100644 index 000000000000..426ad3d858b4 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/critical_path_analytics_tests.rs @@ -0,0 +1,179 @@ +//! Verify Core capture reaches the existing analytics endpoint on terminal turns. + +use super::analytics::mount_analytics_capture; +use super::analytics::wait_for_matching_analytics_event; +use anyhow::Result; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use codex_features::Feature; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path_regex; + +#[derive(Clone, Copy)] +enum Finish { + Complete, + Interrupt, +} + +#[derive(Clone, Copy)] +enum Capture { + Default, + Enabled, + Disabled, +} + +#[test_case::test_case(Finish::Complete, Capture::Default; "complete_default")] +#[test_case::test_case(Finish::Complete, Capture::Enabled; "complete_enabled")] +#[test_case::test_case(Finish::Complete, Capture::Disabled; "complete_disabled")] +#[test_case::test_case(Finish::Interrupt, Capture::Default; "interrupt_default")] +#[test_case::test_case(Finish::Interrupt, Capture::Enabled; "interrupt_enabled")] +#[test_case::test_case(Finish::Interrupt, Capture::Disabled; "interrupt_disabled")] +#[tokio::test] +async fn critical_path_reaches_turn_event(finish: Finish, capture: Capture) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("response-1"), + responses::ev_assistant_message("answer", "Done"), + responses::ev_completed("response-1"), + ]); + let mut response = ResponseTemplate::new(200).set_body_raw(body, "text/event-stream"); + if matches!(finish, Finish::Interrupt) { + response = response.set_delay(Duration::from_secs(60)); + } + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(response) + .mount(&server) + .await; + let codex_home = TempDir::new()?; + let mut features = BTreeMap::from([(Feature::RuntimeMetrics, true)]); + match capture { + Capture::Default => {} + Capture::Enabled => { + features.insert(Feature::CriticalPathTiming, true); + } + Capture::Disabled => { + features.insert(Feature::CriticalPathTiming, false); + } + } + MockResponsesConfig::new(&server.uri()) + .with_root_config(&format!("chatgpt_base_url = {:?}", server.uri())) + .with_provider_config("supports_websockets = false") + .with_features(&features) + .write(codex_home.path())?; + mount_analytics_capture(&server, codex_home.path()).await?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_managed_config() + .build_initialized() + .await?; + let thread = app.start_thread(ThreadStartParams::default()).await?.thread; + let params = TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "Hello".into(), + text_elements: Vec::new(), + }], + ..Default::default() + }; + let (turn_id, expected_status) = match finish { + Finish::Complete => { + let completed = app.start_turn_and_wait_for_completion(params).await?; + (completed.turn.id, "completed") + } + Finish::Interrupt => { + let started: TurnStartResponse = app + .request(|request_id| ClientRequest::TurnStart { request_id, params }) + .await?; + // Wait for sampling to begin before interrupting, without a timing-dependent sleep. + timeout(Duration::from_secs(10), async { + loop { + if server + .received_requests() + .await + .unwrap_or_default() + .iter() + .any(|r| r.url.path().ends_with("/responses")) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await?; + app.interrupt_turn_and_wait_for_aborted( + thread.id.clone(), + started.turn.id.clone(), + Duration::from_secs(10), + ) + .await?; + (started.turn.id, "interrupted") + } + }; + let event = wait_for_matching_analytics_event(&server, Duration::from_secs(30), |event| { + event["event_type"] == "codex_turn_event" && event["event_params"]["turn_id"] == turn_id + }) + .await?; + let p = &event["event_params"]; + assert_eq!( + json!([p["thread_id"], p["status"]]), + json!([thread.id, expected_status]) + ); + let fields = [ + "critical_path_duration_ms", + "critical_path_inference_ms", + "critical_path_auto_review_ms", + "critical_path_user_wait_ms", + "critical_path_tools_ms", + "critical_path_overhead_ms", + "critical_path_unknown_ms", + ]; + if matches!(capture, Capture::Enabled) { + assert_eq!(p["critical_path_status"], "complete"); + assert_eq!(p[fields[0]], p["duration_ms"]); + let total = p[fields[0]] + .as_u64() + .expect("complete capture has a numeric total"); + let sum: u64 = fields[1..] + .iter() + .map(|key| { + p[key] + .as_u64() + .expect("complete capture has numeric categories") + }) + .sum(); + assert!(sum <= total && total - sum <= 5); + assert_eq!( + json!([ + p["critical_path_user_wait_ms"], + p["critical_path_tools_ms"], + p["critical_path_auto_review_ms"], + p["critical_path_unknown_ms"], + ]), + json!([0, 0, 0, 0]) + ); + } else { + assert_eq!(p["critical_path_status"], "disabled"); + assert_eq!( + fields.map(|key| p.get(key)), + [Some(&serde_json::Value::Null); 7] + ); + } + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index aded29d0d9ff..7532c62a6417 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -48,6 +48,8 @@ mod connection_handling_websocket; mod connection_handling_websocket_compression; #[cfg(unix)] mod connection_handling_websocket_unix; +#[path = "critical_path_analytics_tests.rs"] +mod critical_path_analytics; #[cfg(unix)] mod curated_mcp_sync; mod current_time; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 84d1d05ef73e..adac4a295a9d 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -72,6 +72,7 @@ pub(crate) struct TurnTimingState { struct TurnTimingStateInner { started_at: Option, started_at_unix_secs: Option, + completed_at: Option<(Instant, i64)>, item_started_at_ms: HashMap, first_token_at: Option, first_message_at: Option, @@ -79,6 +80,7 @@ struct TurnTimingStateInner { #[derive(Debug, Default)] struct TurnProfileState { + capture_enabled: bool, path: Option, tool_dependency: Option, started_at: Option, @@ -116,6 +118,7 @@ impl TurnTimingState { pub(crate) fn enable_critical_path(&self) { let mut state = self.profile_state(); if state.completed_profile.is_none() { + state.capture_enabled = true; state.path = Some(CriticalPath::default()); } } @@ -140,6 +143,7 @@ impl TurnTimingState { let started_at_unix_ms = now_unix_timestamp_ms(); let mut state = self.state.lock().await; state.started_at = Some(started_at); + state.completed_at = None; state.started_at_unix_secs = Some(started_at_unix_ms / 1000); state.item_started_at_ms.clear(); state.first_token_at = None; @@ -169,9 +173,11 @@ impl TurnTimingState { pub(crate) async fn complete_profile_and_duration_ms( &self, ) -> (Option, Option, TurnProfile) { - let completed_at_instant = Instant::now(); - let state = self.state.lock().await; - let completed_at = Some(now_unix_timestamp_secs()); + let mut state = self.state.lock().await; + // Repeated terminal notifications must retain the original measurement window. + let (completed_at_instant, completed_at) = *state + .completed_at + .get_or_insert_with(|| (Instant::now(), now_unix_timestamp_secs())); let duration_ms = state.started_at.map(|started_at| { i64::try_from( completed_at_instant @@ -181,7 +187,7 @@ impl TurnTimingState { .unwrap_or(i64::MAX) }); let profile = self.profile_state().complete(completed_at_instant); - (completed_at, duration_ms, profile) + (Some(completed_at), duration_ms, profile) } pub(crate) async fn time_to_first_token_ms(&self) -> Option { @@ -296,6 +302,7 @@ impl TurnProfileState { let path = self.path.clone(); *self = Self { path, + capture_enabled: self.capture_enabled, started_at: Some(started_at), last_transition_at: Some(started_at), ..Self::default() @@ -412,6 +419,7 @@ impl TurnProfileState { }; let mut profile = TurnProfile { + critical_path: report::completed_critical_path(self, now), before_first_sampling_ms: duration_to_u64_ms(self.before_first_sampling), sampling_ms: duration_to_u64_ms(self.sampling), compaction_ms: duration_to_u64_ms(self.compaction), @@ -535,6 +543,8 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { } } +mod report; + #[cfg(test)] #[path = "turn_timing_tests.rs"] mod tests; diff --git a/codex-rs/core/src/turn_timing/report.rs b/codex-rs/core/src/turn_timing/report.rs new file mode 100644 index 000000000000..4c82cbf91ae7 --- /dev/null +++ b/codex-rs/core/src/turn_timing/report.rs @@ -0,0 +1,26 @@ +//! Report the frozen critical path over the same interval as the turn profile. + +use super::TurnProfileState; +use super::duration_to_u64_ms; +use codex_analytics::TurnCriticalPath; +use codex_analytics::TurnCriticalPathDurations; +use std::time::Instant; + +pub(super) fn completed_critical_path(state: &TurnProfileState, end: Instant) -> TurnCriticalPath { + if !state.capture_enabled { + return TurnCriticalPath::Disabled; + } + let (Some(path), Some(start)) = (&state.path, state.started_at) else { + return TurnCriticalPath::Incomplete; + }; + let totals = path.durations(start, end); + TurnCriticalPath::Complete(TurnCriticalPathDurations { + duration_ms: duration_to_u64_ms(end.saturating_duration_since(start)), + inference_ms: duration_to_u64_ms(totals.inference), + auto_review_ms: duration_to_u64_ms(totals.auto_review), + user_wait_ms: duration_to_u64_ms(totals.user_wait), + tools_ms: duration_to_u64_ms(totals.tools), + overhead_ms: duration_to_u64_ms(totals.overhead), + unknown_ms: duration_to_u64_ms(totals.unknown), + }) +} diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 1045ad16afed..d821fc6c2ae1 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -245,6 +245,7 @@ fn turn_profile_breaks_down_sampling_blocking_and_retry_overhead() { assert_eq!( state.complete(started_at + Duration::from_millis(1_300)), TurnProfile { + critical_path: codex_analytics::TurnCriticalPath::Disabled, before_first_sampling_ms: 100, sampling_ms: 700, compaction_ms: 0, @@ -263,6 +264,7 @@ fn turn_profile_counts_compaction_as_an_exclusive_phase() { let started_at = Instant::now(); let mut state = TurnProfileState { path: Some(CriticalPath::default()), + capture_enabled: true, ..Default::default() }; state.start(started_at); @@ -282,6 +284,17 @@ fn turn_profile_counts_compaction_as_an_exclusive_phase() { assert_eq!( state.complete(started_at + Duration::from_millis(1_100)), TurnProfile { + critical_path: codex_analytics::TurnCriticalPath::Complete( + codex_analytics::TurnCriticalPathDurations { + duration_ms: 1100, + inference_ms: 800, + auto_review_ms: 0, + user_wait_ms: 0, + tools_ms: 0, + overhead_ms: 300, + unknown_ms: 0, + } + ), before_first_sampling_ms: 200, sampling_ms: 300, compaction_ms: 500, @@ -310,7 +323,22 @@ async fn turn_profile_and_duration_share_a_completion_instant() { .mark_turn_started(Instant::now() - Duration::from_millis(100)) .await; - let (_, duration_ms, profile) = state.complete_profile_and_duration_ms().await; + state.enable_critical_path(); + let result = state.complete_profile_and_duration_ms().await; + assert_eq!(state.complete_profile_and_duration_ms().await, result); + let (_, duration_ms, profile) = result; + assert_eq!( + profile.critical_path, + codex_analytics::TurnCriticalPath::Complete(codex_analytics::TurnCriticalPathDurations { + duration_ms: duration_ms.unwrap() as u64, + inference_ms: 0, + auto_review_ms: 0, + user_wait_ms: 0, + tools_ms: 0, + overhead_ms: duration_ms.unwrap() as u64, + unknown_ms: 0, + }) + ); let classified_ms = profile.before_first_sampling_ms + profile.sampling_ms + profile.compaction_ms @@ -331,6 +359,7 @@ fn capture_preserves_phases_and_freezes_missing_dependencies() { let mut state = TurnProfileState::default(); state.start(started_at); assert!(state.path_at(time_after_turn_start(10)).is_none()); + state.capture_enabled = true; state.path = Some(CriticalPath::default()); assert!(state.begin_sampling(time_after_turn_start(10))); state.end_phase(time_after_turn_start(20), TurnProfilePhase::Sampling); @@ -350,6 +379,18 @@ fn capture_preserves_phases_and_freezes_missing_dependencies() { ..PathDurations::default() } ); + assert_eq!( + profile.critical_path, + codex_analytics::TurnCriticalPath::Complete(codex_analytics::TurnCriticalPathDurations { + duration_ms: 60, + inference_ms: 10, + auto_review_ms: 0, + user_wait_ms: 0, + tools_ms: 0, + overhead_ms: 20, + unknown_ms: 30, + }) + ); let frozen_path = state.path.clone(); state.end_phase(time_after_turn_start(70), TurnProfilePhase::ToolBlocking); assert_eq!(state.complete(time_after_turn_start(80)), profile); @@ -363,7 +404,11 @@ async fn invalidation_and_late_guard_drop_cannot_restore_a_frozen_path() { timing.mark_turn_started(Instant::now()).await; let guard = timing.begin_tool_blocking(Some(ToolBatchTiming::default())); timing.invalidate_critical_path(); - timing.complete_profile_and_duration_ms().await; + let (_, _, profile) = timing.complete_profile_and_duration_ms().await; + assert_eq!( + profile.critical_path, + codex_analytics::TurnCriticalPath::Incomplete + ); timing.enable_critical_path(); drop(guard); assert!(timing.snapshot_path(Instant::now()).is_none()); @@ -412,3 +457,30 @@ async fn completed_batch_guard_retains_its_path_across_turns() { assert_eq!(totals.unknown, Duration::ZERO); assert!(totals.tools >= Duration::from_secs(1)); } + +#[test] +fn critical_path_report_retains_fractional_millisecond_rounding_and_compaction() { + let start = Instant::now(); + let mut state = TurnProfileState { + capture_enabled: true, + path: Some(CriticalPath::default()), + ..Default::default() + }; + state.start(start); + state.begin_compaction(start); + let end = start + Duration::from_nanos(1_999_999); + state.end_phase(end, TurnProfilePhase::Compaction); + let profile = state.complete(end); + assert_eq!( + profile.critical_path, + codex_analytics::TurnCriticalPath::Complete(codex_analytics::TurnCriticalPathDurations { + duration_ms: 1, + inference_ms: 1, + auto_review_ms: 0, + user_wait_ms: 0, + tools_ms: 0, + overhead_ms: 0, + unknown_ms: 0, + }) + ); +} From 0bc73bbeb18ddadf69c2e4c0da4322949de73dc6 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 24 Oct 2026 23:12:22 +0000 Subject: [PATCH 037/287] Add opt-in protocol discovery for local stdio MCP servers (#57210) ## Why Stdio servers without `CODEX_MCP_PROTOCOL_VERSION` use the legacy handshake, even when they support MCP `2026-07-28`. ## What changed - Add the disabled-by-default `mcp_connection_sharing` feature to enable discovery for eligible local stdio servers. - Recover from failed or late discovery with a fresh handshake when needed, waiting for the previous helper to release resources before restarting. - Retain a working legacy connection when an upgrade attempt fails for the same account, except on authentication errors. - Keep tool catalogs separate by protocol and invalidate legacy catalogs when discovery negotiates the modern protocol. ## Testing Add regression coverage for failed and late discovery, legacy fallback, protocol-specific catalog reuse, message limits, and Windows descendant cleanup. GitOrigin-RevId: 36c675ef3b81d32fccf06ccbddda2312cfe602c9 --- .../request_processors/config_processor.rs | 1 + codex-rs/cli/tests/daemon_startup.rs | 3 +- codex-rs/codex-mcp/src/connection_manager.rs | 214 +++++--- .../codex-mcp/src/connection_manager_tests.rs | 50 +- codex-rs/codex-mcp/src/mcp/mod.rs | 2 + codex-rs/codex-mcp/src/mcp/mod_tests.rs | 3 + codex-rs/codex-mcp/src/rmcp_client.rs | 88 ++- codex-rs/codex-mcp/src/runtime.rs | 56 +- codex-rs/codex-mcp/src/server.rs | 32 ++ codex-rs/codex-mcp/src/tool_catalog_cache.rs | 34 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/managed_features.rs | 1 + codex-rs/core/src/config/mod.rs | 1 + codex-rs/core/src/config/runtime_refresh.rs | 1 + codex-rs/core/tests/common/process.rs | 3 +- codex-rs/core/tests/suite/mcp_tool_cache.rs | 8 +- codex-rs/features/src/lib.rs | 8 + .../src/bin/test_mcp_2026_stdio_server.rs | 97 +++- .../src/bounded_stdio_transport.rs | 16 + .../rmcp-client/src/http_client_adapter.rs | 10 +- .../rmcp-client/src/local_stdio_transport.rs | 238 ++++++++- codex-rs/rmcp-client/src/protocol_mode.rs | 16 +- codex-rs/rmcp-client/src/rmcp_client.rs | 160 ++++-- codex-rs/rmcp-client/src/stdio_handshake.rs | 115 ++++ .../rmcp-client/src/stdio_server_launcher.rs | 502 ++++++++++++++---- .../tests/mcp_2026_message_limits.rs | 15 +- codex-rs/rmcp-client/tests/mcp_2026_stdio.rs | 45 +- codex-rs/utils/pty/src/process_group.rs | 27 + codex-rs/utils/pty/src/win/job.rs | 170 +++++- codex-rs/utils/pty/src/windows_tests.rs | 31 +- 30 files changed, 1641 insertions(+), 312 deletions(-) create mode 100644 codex-rs/rmcp-client/src/stdio_handshake.rs diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index bbfc95215185..3726468eeb1d 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -72,6 +72,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ BACKGROUND_PAGINATED_ROLLOUT_MIGRATION_FEATURE, "codex_apps_mcp_2026_07_28", "mcp_2026_07_28", + "mcp_connection_sharing", "memories", "mentions_v2", "remote_control", diff --git a/codex-rs/cli/tests/daemon_startup.rs b/codex-rs/cli/tests/daemon_startup.rs index afe205882642..ae2fe3eefc2b 100644 --- a/codex-rs/cli/tests/daemon_startup.rs +++ b/codex-rs/cli/tests/daemon_startup.rs @@ -135,7 +135,8 @@ async fn restrictive_launcher_uses_embedded_if_daemon_cannot_start() -> Result<( .kill_on_drop(true); let mut child = command.spawn()?; ensure!( - job.assign_and_resume_process(child.id().context("missing CLI pid")?)?, + job.assign_and_resume_process(child.id().context("missing CLI pid")?)? + .is_some(), "CLI job assignment failed" ); let status = child.wait().await?; diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 125d934cb048..2ad43785d258 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -31,12 +31,14 @@ pub(crate) use tool_catalog::BindingCatalogRevision; pub use tool_catalog::tool_is_model_visible; use codex_config::McpServerAuth; +use std::cell::LazyCell; use std::collections::HashMap; use std::sync::Arc; use std::sync::OnceLock; use std::sync::atomic::Ordering; use std::time::Duration; +use crate::McpProtocolMode; use crate::binding::call_tool_result_from_rmcp; use crate::catalog::McpServerSource; use crate::elicitation::ElicitationRequestManager; @@ -58,7 +60,6 @@ use crate::runtime::McpRuntimeInput; use crate::runtime::McpStartupPolicy; use crate::server::McpServerConnectionIdentity; use crate::server::McpServerMetadata; -use crate::tool_catalog_cache::McpToolCatalogCacheContext; use crate::tools::ToolFilter; use crate::tools::ToolInfo; use crate::tools::filter_tools; @@ -85,6 +86,8 @@ use codex_protocol::protocol::McpStartupFailureReason; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_rmcp_client::determine_streamable_http_auth_status_from_credentials; +use futures::FutureExt; +use futures::future::join_all; use tokio::sync::watch; use tokio::task::JoinSet; use tracing::warn; @@ -94,6 +97,7 @@ static LIVE_CONNECTIONS: Gauge = Gauge::new("mcp.connections.live"); pub(crate) struct McpServerConnection { identity: Option, client: AsyncManagedClient, + legacy_fallback: Option>>>, // Startup-only budget; changing it must not replace a ready connection. startup_timeout: Duration, startup_trigger: Option>, @@ -101,6 +105,54 @@ pub(crate) struct McpServerConnection { } impl McpServerConnection { + fn retained_legacy(&self) -> Option<&Arc> { + self.legacy_fallback.as_deref().and_then(OnceLock::get) + } + + fn may_use_legacy_fallback(&self) -> bool { + self.legacy_fallback + .as_ref() + .is_some_and(|fallback| fallback.get().is_some() || self.client.client.peek().is_none()) + } + + /// Keep the working private connection if a singleton cannot start a second process. + fn retain_on_failed_discovery( + self: &Arc, + replacement: &mut AsyncManagedClient, + ) -> Arc>> { + let previous = Arc::clone(self.retained_legacy().unwrap_or(self)); + let fallback = Arc::new(OnceLock::new()); + let selected = Arc::clone(&fallback); + let cancellation = replacement.cancel_token.clone(); + replacement.client = replacement + .client + .clone() + .then(move |outcome| async move { + let Err(error @ StartupOutcomeError::Failed { .. }) = &outcome else { + return outcome; + }; + if error.is_authentication_required() { + return outcome; + } + error.wait_for_shutdown().await; + let Some(client) = previous.client.ready_client() else { + return outcome; + }; + if client.client.is_closed().await { + return outcome; + } + if cancellation.is_cancelled() || previous.client.cancel_token.is_cancelled() { + return Err(StartupOutcomeError::Cancelled); + } + // The connection owns the startup token and the capabilities for this transport. + selected.get_or_init(|| previous); + Ok(client) + }) + .boxed() + .shared(); + fallback + } + async fn reusable_client( &self, desired: &McpServerConnectionIdentity, @@ -186,7 +238,7 @@ impl Drop for McpServerConnection { #[derive(Clone)] struct McpServerView { connection: Arc, - protocol_mode: crate::McpProtocolMode, + protocol_mode: McpProtocolMode, startup_readiness: McpStartupReadiness, metadata: McpServerMetadata, tool_filter: ToolFilter, @@ -268,6 +320,11 @@ impl McpServerView { } } +pub(crate) struct PreviousMcpConnections<'a> { + pub(crate) connections: &'a McpConnectionSet, + pub(crate) same_owner: bool, +} + /// A published view over a set of running MCP server connections. pub(crate) struct McpConnectionSet { config: Option>, @@ -290,11 +347,13 @@ impl McpConnectionSet { pub async fn new( ema_auth: Option, existing_ema: Option<&Self>, - previous: Option<&Self>, + previous: Option>, publication_gate: McpPublicationGate, input: McpRuntimeInput, elicitation_router: ElicitationRequestRouter, ) -> Self { + let same_owner = previous.as_ref().is_some_and(|state| state.same_owner); + let previous = previous.map(|state| state.connections); let trusted_access = TrustedAccessContext::from_runtime(&input); let McpRuntimeInput { startup_policy, @@ -437,7 +496,7 @@ impl McpConnectionSet { let uses_ema_auth = config.xaa_enabled && matches!(configured_config.auth, McpServerAuth::EmaAuth); let protocol_mode = if uses_ema_auth { - crate::McpProtocolMode::V20260728 + McpProtocolMode::V20260728 } else { protocol_mode }; @@ -525,6 +584,13 @@ impl McpConnectionSet { } else { chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider) }; + let discover_local_stdio = config.discover_local_stdio + && server.can_discover_local_stdio(&server_name, runtime_auth_provider.as_ref()); + let protocol_mode = if discover_local_stdio { + McpProtocolMode::DiscoverLocalStdio + } else { + protocol_mode + }; if is_host_owned_codex_apps { event_stream_connection = Some(Arc::new(EventStreamConnectionSettings { server: server.clone(), @@ -561,24 +627,54 @@ impl McpConnectionSet { ); let expected_protocol_mode = match &configured_config.transport { McpServerTransportConfig::StreamableHttp { .. } => Some(protocol_mode), - McpServerTransportConfig::Stdio { .. } - if protocol_mode == crate::McpProtocolMode::Legacy => + McpServerTransportConfig::Stdio { .. } => server.stdio_protocol_mode(protocol_mode), + }; + let mut tool_catalog_cache_context = LazyCell::new(|| { + if server_name == CODEX_APPS_MCP_SERVER_NAME + || server.requires_read_only_mcp_tools() + || matches!(configured_config.auth, McpServerAuth::EmaAuth) { - Some(crate::McpProtocolMode::Legacy) + return None; } - McpServerTransportConfig::Stdio { env, .. } => match env - .as_ref() - .and_then(|variables| variables.get("CODEX_MCP_PROTOCOL_VERSION")) - { - None => Some(crate::McpProtocolMode::Legacy), - Some(version) - if version == rmcp::model::ProtocolVersion::V_2026_07_28.as_str() => - { - Some(protocol_mode) + let environment = resolved_environment.as_ref().ok()?; + let cache_mode = match expected_protocol_mode? { + McpProtocolMode::DiscoverLocalStdio | McpProtocolMode::Legacy => { + McpProtocolMode::Legacy } - Some(_) => None, - }, + McpProtocolMode::V20260728 => McpProtocolMode::V20260728, + }; + let mut context = tool_catalog_cache.context( + &server_name, + &configured_config, + &runtime_context, + environment.as_ref(), + (&client_elicitation_capability, &client_mcp_extensions), + Some((&connection_identity, cache_mode, server.is_agent_plugin())), + )?; + context.track_tool_list_changes = config.mcp_tool_list_changed; + Some(context) + }); + let defer_startup = allow_deferred_startup + && !tool_plugin_context.is_selected_plugin_mcp_server(&server_name) + && tool_catalog_cache_context + .as_ref() + .and_then(|context| { + context.current_revision_if(|tools| { + tools + .iter() + .any(|tool| configured_tool_filter.allows(&tool.tool.name)) + }) + }) + .is_some(); + // A subagent using legacy tool definitions must launch with the same handshake. + let expected_protocol_mode = if defer_startup + && expected_protocol_mode == Some(McpProtocolMode::DiscoverLocalStdio) + { + Some(McpProtocolMode::Legacy) + } else { + expected_protocol_mode }; + let protocol_mode = expected_protocol_mode.unwrap_or(protocol_mode); if matches!(configured_config.auth, McpServerAuth::EmaAuth) && existing_ema.is_some() { if let Some(previous_view) = existing_ema .filter(|_| elicitation_previous.is_some()) @@ -640,9 +736,11 @@ impl McpConnectionSet { // Post-start publications may only retain the original EMA connection. continue; } + let mut legacy_to_upgrade = None; if let Some(previous_view) = reusable_previous.and_then(|previous| previous.servers.get(&server_name)) && previous_view.refresh_tool_list == config.mcp_tool_list_changed + && (same_owner || !previous_view.connection.may_use_legacy_fallback()) { let connection = Arc::clone(&previous_view.connection); let reusable_pending_startup = connection.identity.as_ref() @@ -668,17 +766,19 @@ impl McpConnectionSet { } else { None }; + let ready = connection + .reusable_client(&connection_identity) + .await + .filter(|_| previous_view.catalog_item_limit == catalog_item_limit); if reusable_pending_startup || unchanged_auth_failure.is_some() - || connection - .reusable_client(&connection_identity) - .await - .is_some_and(|client| { - previous_view.catalog_item_limit == catalog_item_limit - && expected_protocol_mode.is_some_and(|expected| { - client.client.protocol_mode() == expected - }) + || ready.as_ref().is_some_and(|client| { + expected_protocol_mode.is_some_and(|expected| { + client.client.protocol_mode() == expected + || (previous_view.protocol_mode == expected + && connection.retained_legacy().is_some()) }) + }) { let pending_client = reusable_pending_startup.then(|| connection.client.clone()); @@ -746,34 +846,22 @@ impl McpConnectionSet { } continue; } + if same_owner + && expected_protocol_mode == Some(McpProtocolMode::DiscoverLocalStdio) + && previous_view.protocol_mode == McpProtocolMode::Legacy + && connection.identity.as_ref() == Some(&connection_identity) + && !connection.client.cancel_token.is_cancelled() + && ready.is_some_and(|client| { + client.client.protocol_mode() == McpProtocolMode::Legacy + }) + { + legacy_to_upgrade = Some(connection); + } } let cancel_token = startup_cancellation_token.child_token(); - let mut tool_catalog_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME - || server.requires_read_only_mcp_tools() - || matches!(configured_config.auth, McpServerAuth::EmaAuth) - { - None - } else if let Ok(environment) = resolved_environment.as_ref() { - tool_catalog_cache.context( - &server_name, - &configured_config, - &runtime_context, - environment.as_ref(), - (&client_elicitation_capability, &client_mcp_extensions), - Some(( - &connection_identity, - protocol_mode, - server.is_agent_plugin(), - )), - ) - } else { - None - }; - if let Some(context) = &mut tool_catalog_cache_context { - context.track_tool_list_changes = config.mcp_tool_list_changed; - } + let tool_catalog_cache_context = tool_catalog_cache_context.take(); let has_runtime_auth = runtime_auth_provider.is_some(); - let async_managed_client = AsyncManagedClient::new( + let mut async_managed_client = AsyncManagedClient::new( ema_auth.clone(), server_name.clone(), startup_submit_id.clone(), @@ -803,17 +891,12 @@ impl McpConnectionSet { protocol_mode, catalog_item_limit, ); - let defer_startup = allow_deferred_startup - && !tool_plugin_context.is_selected_plugin_mcp_server(&server_name) - && async_managed_client - .tool_catalog_cache_context - .as_ref() - .and_then(McpToolCatalogCacheContext::current_tools) - .is_some_and(|tools| { - tools - .into_iter() - .any(|tool| configured_tool_filter.allows(&tool.tool.name)) - }); + // A forced reconnect must also wait for the previously published helper to exit. + if let Some(previous) = existing_ema.and_then(|set| set.servers.get(&server_name)) { + async_managed_client.wait_for_failed_predecessor(&previous.connection.client); + } + let legacy_fallback = legacy_to_upgrade + .map(|connection| connection.retain_on_failed_discovery(&mut async_managed_client)); let (startup_trigger, startup_receiver) = if defer_startup { let (trigger, receiver) = watch::channel(false); (Some(trigger), Some(receiver)) @@ -826,6 +909,7 @@ impl McpConnectionSet { connection: Arc::new(McpServerConnection { identity: Some(connection_identity), client: async_managed_client.clone(), + legacy_fallback, startup_timeout, startup_trigger, _diagnostics_guard: LIVE_CONNECTIONS.track(), @@ -1117,9 +1201,7 @@ impl McpConnectionSet { .collect::>(); // Keep cleanup alive if an interrupt cancels the refresh that requested it. let shutdown_task = tokio::spawn(async move { - for connection in connections { - connection.shutdown().await; - } + join_all(connections.iter().map(|connection| connection.shutdown())).await; }); if let Err(error) = shutdown_task.await { warn!("MCP client shutdown task failed: {error}"); @@ -1252,6 +1334,8 @@ impl McpConnectionSet { .filter(|(name, _)| include_server(name)) .filter_map(|(name, view)| { view.connection + .retained_legacy() + .unwrap_or(&view.connection) .client .server_capabilities .lock() diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 06e4c3b8d53d..08323befbdfb 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -148,6 +148,7 @@ impl McpConnectionSet { client, startup_timeout: DEFAULT_STARTUP_TIMEOUT, startup_trigger: None, + legacy_fallback: None, _diagnostics_guard: LIVE_CONNECTIONS.track(), }), metadata: McpServerMetadata { @@ -617,6 +618,7 @@ async fn connection_statuses_observe_clients_without_starting_them() { error: "broken".to_string(), is_authentication_required: false, is_ema_authentication_required: false, + shutdown: None, }, ), ( @@ -625,6 +627,7 @@ async fn connection_statuses_observe_clients_without_starting_them() { error: "login".to_string(), is_authentication_required: true, is_ema_authentication_required: false, + shutdown: None, }, ), ( @@ -700,6 +703,7 @@ async fn connection_statuses_follow_latest_reconnect_outcome() { error: "retry failed".to_string(), is_authentication_required: attempt == 0, is_ema_authentication_required: false, + shutdown: None, }), _ => Ok(recovered), } @@ -866,6 +870,7 @@ fn create_test_manager_with_failed_apps_startup( error: "startup failed".to_string(), is_authentication_required: false, is_ema_authentication_required: false, + shutdown: None, })) .boxed() .shared(); @@ -3194,6 +3199,7 @@ async fn stable_catalog_revisions_ignore_terminal_optional_server_failures() { error: "optional startup failed".to_string(), is_authentication_required: false, is_ema_authentication_required: false, + shutdown: None, }, )) .boxed() @@ -3397,8 +3403,10 @@ async fn capture_binding_uses_cache_published_during_optional_startup() { let server_config: McpServerConfig = serde_json::from_value(serde_json::json!({ "command": "pending-mcp" })) .expect("server configuration"); - let cache_context = McpToolCatalogCache::default() - .context( + let cache = McpToolCatalogCache::default(); + let identity = reusable_server_identity("pending", &server_config, &runtime_context); + let catalog = |mode| { + cache.context( "pending", &server_config, &runtime_context, @@ -3407,9 +3415,17 @@ async fn capture_binding_uses_cache_published_during_optional_startup() { &ElicitationCapability::default(), &ClientMcpExtensions::default(), ), - /*connection_identity*/ None, + Some((&identity, mode, /*agent_plugin*/ false)), ) - .expect("shared catalog"); + }; + let legacy = catalog(McpProtocolMode::Legacy).expect("legacy catalog"); + legacy.publish_if_newest( + legacy.begin_fetch(), + &[create_test_tool("pending", "legacy_tool")], + /*meta*/ None, + ); + assert!(catalog(McpProtocolMode::DiscoverLocalStdio).is_none()); + let cache_context = catalog(McpProtocolMode::V20260728).expect("modern catalog"); let (mut client, started, _release) = create_gated_async_managed_client( create_test_managed_client(vec![create_test_tool("pending", "live_tool")]).await, ); @@ -3427,7 +3443,7 @@ async fn capture_binding_uses_cache_published_during_optional_startup() { assert!(futures::poll!(&mut binding).is_pending()); started .await - .expect("optional startup began without a cache"); + .expect("a catalog from the legacy handshake cannot satisfy discovery"); cache_context.publish_if_newest( cache_context.begin_fetch(), &[create_test_tool("pending", "peer_tool")], @@ -4144,6 +4160,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_when_client_startup_fails() error: "startup failed".to_string(), is_authentication_required: false, is_ema_authentication_required: false, + shutdown: None, }, )) .boxed() @@ -4304,6 +4321,7 @@ async fn later_tool_list_retries_after_failed_reconnect_and_keeps_cached_tools() error: "recreated startup failed".to_string(), is_authentication_required: false, is_ema_authentication_required: false, + shutdown: None, }) } else { Ok(recovered_client) @@ -5110,6 +5128,7 @@ fn mcp_init_error_display_prompts_for_login_when_auth_required() { error: "OAuth refresh token was rejected: invalid_grant".to_string(), is_authentication_required: true, is_ema_authentication_required: false, + shutdown: None, }, ] { let display = mcp_init_error_display( @@ -5142,6 +5161,7 @@ fn mcp_init_error_display_identifies_oauth_reauthentication() { error: "authorization required: Bearer error=\"invalid_token\"".to_string(), is_authentication_required: true, is_ema_authentication_required: false, + shutdown: None, }; let executor_config: McpServerConfig = serde_json::from_value(serde_json::json!({ "url": "https://example.com/mcp", @@ -5206,6 +5226,7 @@ fn mcp_startup_failure_reason_requires_existing_oauth_and_auth_failure() { error: "startup failed".to_string(), is_authentication_required, is_ema_authentication_required: false, + shutdown: None, }; assert_eq!( @@ -5388,6 +5409,7 @@ async fn manager_with_reusable_ready_server( .startup_timeout_sec .unwrap_or(DEFAULT_STARTUP_TIMEOUT), startup_trigger: None, + legacy_fallback: None, _diagnostics_guard: LIVE_CONNECTIONS.track(), }), metadata: McpServerMetadata::from(&server), @@ -5426,7 +5448,10 @@ async fn reconcile_reusable_server_with_mcp_config( McpConnectionSet::new( /*ema_auth*/ None, /*existing_ema*/ None, - Some(previous), + Some(PreviousMcpConnections { + connections: previous, + same_owner: true, + }), McpPublicationGate::already_published(), McpRuntimeInput { network_policy_metadata: None, @@ -5839,6 +5864,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a .startup_timeout_sec .unwrap_or(DEFAULT_STARTUP_TIMEOUT), startup_trigger: None, + legacy_fallback: None, _diagnostics_guard: LIVE_CONNECTIONS.track(), }), metadata: McpServerMetadata::from(&server), @@ -6129,6 +6155,7 @@ async fn reconciliation_retries_non_oauth_authentication_failures() { error: "bearer token rejected".to_string(), is_authentication_required: true, is_ema_authentication_required: false, + shutdown: None, })) .boxed() .shared(); @@ -6389,7 +6416,10 @@ async fn reconciliation_replaces_connection_when_protocol_mode_changes() { let reconciled = McpConnectionSet::new( /*ema_auth*/ None, /*existing_ema*/ None, - Some(&previous), + Some(PreviousMcpConnections { + connections: &previous, + same_owner: true, + }), McpPublicationGate::already_published(), McpRuntimeInput { network_policy_metadata: None, @@ -6450,7 +6480,10 @@ async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabl let reconciled = McpConnectionSet::new( /*ema_auth*/ None, /*existing_ema*/ None, - Some(&previous), + Some(PreviousMcpConnections { + connections: &previous, + same_owner: true, + }), McpPublicationGate::already_published(), McpRuntimeInput { network_policy_metadata: None, @@ -6667,6 +6700,7 @@ async fn reconciliation_replaces_closed_connections() -> anyhow::Result<()> { .startup_timeout_sec .unwrap_or(DEFAULT_STARTUP_TIMEOUT), startup_trigger: None, + legacy_fallback: None, _diagnostics_guard: LIVE_CONNECTIONS.track(), }); diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index fa5242e3e7a1..b34a82218362 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -190,6 +190,8 @@ pub struct McpConfig { pub non_prefixed_mcp_tool_servers: Vec, /// Protocol mode for servers other than the host-owned Codex Apps registration. pub protocol_mode: McpProtocolMode, + /// Try protocol discovery before initializing eligible local stdio servers. + pub discover_local_stdio: bool, /// Refresh ordinary server catalogs after tools/list_changed notifications. pub mcp_tool_list_changed: bool, /// Independent protocol mode for the trusted, HTTP Codex Apps registration. diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 68730ba0384c..c066d8c8b303 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -130,6 +130,7 @@ async fn status_snapshot_only_downgrades_oauth_and_ema_authentication_failures() error: "OAuth refresh token was rejected".to_string(), is_authentication_required: true, is_ema_authentication_required: false, + shutdown: None, }; let mut manager = McpConnectionSet::empty(/*prefix_mcp_tool_names*/ true); let mut auth_status_entries = HashMap::new(); @@ -147,6 +148,7 @@ async fn status_snapshot_only_downgrades_oauth_and_ema_authentication_failures() error: "provider temporarily unavailable".to_string(), is_authentication_required: false, is_ema_authentication_required: false, + shutdown: None, }), ), ( @@ -236,6 +238,7 @@ pub(crate) fn test_mcp_config(codex_home: PathBuf) -> McpConfig { prefix_mcp_tool_names: true, non_prefixed_mcp_tool_servers: Vec::new(), protocol_mode: McpProtocolMode::Legacy, + discover_local_stdio: false, mcp_tool_list_changed: true, host_owned_apps_protocol_mode: McpProtocolMode::Legacy, client_elicitation_capability: ElicitationCapability::default(), diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 54d5d4dfe7f3..8e66a9ecfc23 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -166,6 +166,7 @@ impl ManagedClient { pub(crate) type ManagedClientFuture = Shared>>; +type StartupShutdown = Shared>>; #[derive(Default)] struct CodexAppsStartupReconnectState { @@ -368,6 +369,7 @@ impl ManagedClientStartup { .as_ref() .map(McpToolCatalogCacheContext::begin_fetch); let refresh_start = is_codex_apps_mcp_server.then(Instant::now); + let mut discovery_client = None; let outcome = match async { if let Err(error) = validate_mcp_server_name(&server_name) { return Err(error.into()); @@ -399,6 +401,9 @@ impl ManagedClientStartup { ))); } }; + if client.protocol_mode() == McpProtocolMode::DiscoverLocalStdio { + discovery_client = Some(Arc::clone(&client)); + } start_server_task( server_name.clone(), client, @@ -422,8 +427,32 @@ impl ManagedClientStartup { .or_cancel(&cancel_token_for_fut) .await { - Ok(result) => result, - Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), + Ok(mut result) => { + if let Err(StartupOutcomeError::Failed { shutdown, .. }) = &mut result + && let Some(client) = discovery_client + { + // Report the startup error now. A retry waits for the helper to exit. + *shutdown = Some( + tokio::spawn(async move { client.shutdown_before_retry().await }) + .map(|joined| match joined { + Ok(result) => result.map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }) + .boxed() + .shared(), + ); + } + result + } + Err(CancelErr::Cancelled) => { + // A cancelled legacy replacement may still be inside the native spawn. + if let Some(client) = discovery_client + && let Err(error) = client.shutdown_before_retry().await + { + warn!("failed to stop cancelled MCP discovery: {error}"); + } + Err(StartupOutcomeError::Cancelled) + } }; // Log once per startup attempt, including discovery without startup notifications. if let Err(StartupOutcomeError::Failed { error, .. }) = &outcome { @@ -540,6 +569,9 @@ impl AsyncManagedClient { startup_complete: Arc::clone(&startup_complete), server_capabilities: Arc::clone(&server_capabilities), }); + // Startup can publish a legacy fallback. Readers wait for the negotiated catalog. + let tool_catalog_cache_context = tool_catalog_cache_context + .filter(|_| protocol_mode != McpProtocolMode::DiscoverLocalStdio); let client = startup.start(); let startup_reconnect = is_codex_apps_mcp_server.then(|| { let startup = Arc::clone(&startup); @@ -595,6 +627,33 @@ impl AsyncManagedClient { self.ready_client().map(|client| client.client) } + /// Waits for this server's failed predecessor when the replacement actually starts. + pub(crate) fn wait_for_failed_predecessor(&mut self, previous: &Self) { + let Some(Err( + error @ StartupOutcomeError::Failed { + shutdown: Some(shutdown), + .. + }, + )) = previous.client.peek() + else { + return; + }; + let shutdown = shutdown.clone(); + let error = error.clone(); + let client = self.client.clone(); + let startup_complete = Arc::clone(&self.startup_complete); + self.client = async move { + if let Err(stop_error) = shutdown.await { + warn!("cannot restart MCP server after failed cleanup: {stop_error}"); + startup_complete.store(true, Ordering::Release); + return Err(error); + } + client.await + } + .boxed() + .shared(); + } + pub(crate) async fn reconnect_failed_startup(&self) { let Some(startup_reconnect) = self.startup_reconnect.as_ref() else { return; @@ -614,6 +673,7 @@ impl AsyncManagedClient { Err(StartupOutcomeError::Cancelled) => {} Err(error) => { warn!("failed to initialize MCP client during shutdown: {error:#}"); + error.wait_for_shutdown().await; } } } @@ -684,10 +744,22 @@ pub(crate) enum StartupOutcomeError { error: String, is_authentication_required: bool, is_ema_authentication_required: bool, + shutdown: Option, }, } impl StartupOutcomeError { + pub(crate) async fn wait_for_shutdown(&self) { + if let Self::Failed { + shutdown: Some(shutdown), + .. + } = self + && let Err(error) = shutdown.clone().await + { + warn!("failed to stop MCP server after startup failed: {error}"); + } + } + pub(crate) fn is_authentication_required(&self) -> bool { match self { Self::Cancelled => false, @@ -709,6 +781,7 @@ impl From for StartupOutcomeError { is_ema_authentication_required: is_authentication_required && (error.downcast_ref::().is_some() || error.downcast_ref::().is_some()), + shutdown: None, } } } @@ -1013,7 +1086,7 @@ async fn start_server_task( tx_event, elicitation_requests, codex_apps_tools_cache_context, - tool_catalog_cache_context, + mut tool_catalog_cache_context, tool_catalog_fetch_ticket, client_elicitation_capability, client_mcp_extensions, @@ -1050,6 +1123,13 @@ async fn start_server_task( &initialize_result, ); let initialize_result = initialize_result.map_err(StartupOutcomeError::from)?; + if client.protocol_mode() == McpProtocolMode::DiscoverLocalStdio + && !initialize_result.protocol_version.has_initialize() + && let Some(context) = tool_catalog_cache_context.take() + && let Some(ticket) = &tool_catalog_fetch_ticket + { + context.invalidate_if_newest(ticket); + } *server_capabilities .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = @@ -1186,7 +1266,7 @@ fn record_protocol_discovery_metrics( let mode = match mode { McpProtocolMode::Legacy => "legacy", - McpProtocolMode::V20260728 => "auto", + McpProtocolMode::V20260728 | McpProtocolMode::DiscoverLocalStdio => "auto", }; let outcome = match result { Ok(result) if result.protocol_version == ProtocolVersion::V_2026_07_28 => "modern", diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 52ab17007d02..e994fb0f382c 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -53,6 +53,7 @@ use crate::binding::PreparedMcpCall; use crate::client_tool_catalog::CodexAppsToolSnapshot; use crate::connection_manager::BindingCatalogRevision; use crate::connection_manager::McpConnectionSet; +use crate::connection_manager::PreviousMcpConnections; use crate::elicitation::ElicitationLifecycle; use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::ElicitationReviewerHandle; @@ -131,6 +132,7 @@ struct PublishedMcpRuntime { auth: Option, auth_token: Option, auth_manager: Option>, + auth_owner: Option, auth_generation: Arc<()>, plugins_available: bool, ready_selected_capability_roots: Vec, @@ -139,6 +141,23 @@ struct PublishedMcpRuntime { cached_binding: Mutex>, } +/// The account that owns a private helper across credential refreshes. +#[derive(Clone, Copy, PartialEq, Eq)] +struct AuthOwner { + generation: u64, + is_fedramp: bool, +} + +impl AuthOwner { + fn capture(auth: Option<&CodexAuth>, manager: Option<&AuthManager>) -> Option { + let auth = auth?; + Some(Self { + generation: manager?.auth_change_state_for(auth)?.owner_generation, + is_fedramp: auth.is_fedramp_account(), + }) + } +} + fn ensure_host_owned_apps_registration( current: &PublishedMcpRuntime, server: &str, @@ -242,6 +261,7 @@ impl McpRuntime { auth: None, auth_token: None, auth_manager: None, + auth_owner: None, auth_generation: Arc::new(()), plugins_available: false, ready_selected_capability_roots: Vec::new(), @@ -323,22 +343,18 @@ impl McpRuntime { /// Reconciles configured servers and publishes their immutable runtime snapshot. pub async fn replace(&self, input: McpRuntimeInput) { - let current = self.current.load_full(); let mut reconnect = McpReconnectGuard { pending: &self.reconnect_pending, claimed: self.reconnect_pending.swap(false, Ordering::AcqRel), }; - self.publish( - input, - (!reconnect.claimed).then_some(current.connections.as_ref()), - ) - .await; + self.publish(input, /*reuse_previous*/ !reconnect.claimed) + .await; reconnect.claimed = false; } /// Starts fresh connections and returns their complete, refreshed Apps catalog. pub async fn replace_fresh(&self, input: McpRuntimeInput) -> anyhow::Result> { - self.publish(input, /*previous*/ None).await; + self.publish(input, /*reuse_previous*/ false).await; self.latest_hard_refresh_codex_apps_tools_cache().await } @@ -357,7 +373,7 @@ impl McpRuntime { } } - async fn publish(&self, input: McpRuntimeInput, previous: Option<&McpConnectionSet>) { + async fn publish(&self, input: McpRuntimeInput, reuse_previous: bool) { let current = self.current.load_full(); let retain_ema = current.config.is_some(); let ema_auth = (input.config.xaa_enabled @@ -382,7 +398,21 @@ impl McpRuntime { let auth = input.auth.clone(); let auth_manager = input.auth_manager.clone(); let auth_token = auth.as_ref().and_then(|auth| auth.get_token().ok()); - let auth_generation = if current.auth_matches(auth.as_ref()) { + let auth_matches = current.auth_matches(auth.as_ref()); + let auth_owner = AuthOwner::capture(auth.as_ref(), auth_manager.as_deref()); + let same_owner = match (current.auth_owner, auth_owner) { + (Some(previous), Some(next)) => { + previous == next + && current + .auth_manager + .as_ref() + .zip(auth_manager.as_ref()) + .is_some_and(|(previous, next)| Arc::ptr_eq(previous, next)) + } + (None, None) => auth_matches, + _ => false, + }; + let auth_generation = if auth_matches { Arc::clone(¤t.auth_generation) } else { Arc::new(()) @@ -395,7 +425,10 @@ impl McpRuntime { McpConnectionSet::new( ema_auth, retain_ema.then_some(current.connections.as_ref()), - previous, + reuse_previous.then_some(PreviousMcpConnections { + connections: ¤t.connections, + same_owner, + }), publication_gate, input, self.elicitation_router.clone(), @@ -421,6 +454,7 @@ impl McpRuntime { auth, auth_token, auth_manager, + auth_owner, auth_generation, plugins_available, ready_selected_capability_roots, @@ -1129,6 +1163,7 @@ mod tests { auth: None, auth_token: None, auth_manager: None, + auth_owner: None, auth_generation: Arc::new(()), plugins_available: false, ready_selected_capability_roots: Vec::new(), @@ -1204,6 +1239,7 @@ mod tests { auth: None, auth_token: None, auth_manager: None, + auth_owner: None, auth_generation: Arc::new(()), plugins_available: false, ready_selected_capability_roots: Vec::new(), diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index df532ee1eac1..3fa6d853b573 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; +use std::ffi::OsStr; use std::ffi::OsString; use std::path::PathBuf; use std::sync::Arc; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::McpRuntimeContext; use codex_api::SharedAuthProvider; use codex_config::AppToolApproval; @@ -17,6 +19,7 @@ use codex_exec_server::Environment; use codex_login::CodexAuth; use codex_protocol::mcp::ClientMcpExtensions; use codex_rmcp_client::McpOAuthRefreshMode; +use codex_rmcp_client::McpProtocolMode; use codex_rmcp_client::StoredOAuthCredentialSnapshot; use codex_rmcp_client::StoredOAuthTokens; use codex_utils_path_uri::PathUri; @@ -82,6 +85,35 @@ impl EffectiveMcpServer { &self.config } + /// Checks whether Core can discover the protocol of a local stdio server. + /// + /// The reserved `codex_apps` server has a per-chat catalog. Servers with runtime or + /// enterprise-managed credentials need that chat's authentication state. + pub(crate) fn can_discover_local_stdio( + &self, + server_name: &str, + runtime_auth_provider: Option<&SharedAuthProvider>, + ) -> bool { + server_name != CODEX_APPS_MCP_SERVER_NAME + && self.config.is_local_environment() + && runtime_auth_provider.is_none() + && !matches!(self.config.auth, McpServerAuth::EmaAuth) + && self + .stdio_protocol_mode(McpProtocolMode::DiscoverLocalStdio) + .is_some() + } + + /// Returns the handshake a stdio launch would use, or `None` for HTTP or an invalid version. + pub(crate) fn stdio_protocol_mode(&self, mode: McpProtocolMode) -> Option { + let McpServerTransportConfig::Stdio { env, .. } = &self.config.transport else { + return None; + }; + let version = env + .as_ref() + .and_then(|env| env.get("CODEX_MCP_PROTOCOL_VERSION")); + mode.stdio_mode(version.map(OsStr::new)).ok() + } + pub fn enabled(&self) -> bool { self.config.enabled } diff --git a/codex-rs/codex-mcp/src/tool_catalog_cache.rs b/codex-rs/codex-mcp/src/tool_catalog_cache.rs index 8385633bd6d8..a83f8591448a 100644 --- a/codex-rs/codex-mcp/src/tool_catalog_cache.rs +++ b/codex-rs/codex-mcp/src/tool_catalog_cache.rs @@ -184,6 +184,7 @@ impl McpToolCatalogCacheContext { cached_deadline.deadline } + #[cfg(test)] pub(crate) fn current_tools(&self) -> Option> { self.current_tools_or(/*fallback*/ None) } @@ -235,25 +236,28 @@ impl McpToolCatalogCacheContext { state.snapshot = None; } + /// An observed protocol change invalidates a legacy catalog, including any older fetch in flight. + pub(crate) fn invalidate_if_newest(&self, ticket: &McpToolCatalogFetchTicket) { + if let Some(mut state) = self.accept_generation(ticket.generation) { + state.snapshot = None; + } + } + pub(crate) fn publish_if_newest( &self, ticket: McpToolCatalogFetchTicket, tools: &[ToolInfo], meta: Option>, ) { - let mut state = lock_unpoisoned(&self.entry.state); - if state.disabled_by_server || ticket.generation <= state.last_accepted_generation { + let Some(mut state) = self.accept_generation(ticket.generation) else { return; - } - + }; let mut tools = tools.to_vec(); for tool in &mut tools { // Tool annotations affect approval and parallelism decisions, so only the live // connection may supply them. tool.tool.annotations = None; } - state.last_accepted_generation = ticket.generation; - state.optional_startup_deadline = None; state.snapshot = Some(ToolCatalogSnapshot { tools, meta, @@ -261,6 +265,16 @@ impl McpToolCatalogCacheContext { tool_list_revision: ticket.tool_list_revision, }); } + + fn accept_generation(&self, generation: u64) -> Option> { + let mut state = lock_unpoisoned(&self.entry.state); + if state.disabled_by_server || generation <= state.last_accepted_generation { + return None; + } + state.last_accepted_generation = generation; + state.optional_startup_deadline = None; + Some(state) + } } fn lock_unpoisoned(mutex: &Mutex) -> MutexGuard<'_, T> { @@ -417,6 +431,13 @@ impl ToolCatalogTransportIdentity { else { return None; }; + // An unmarked helper can expose different tools after a legacy fallback. + if matches!( + connection_identity, + Some((_, McpProtocolMode::DiscoverLocalStdio, _)) + ) { + return None; + } if env_vars .iter() .any(codex_config::McpServerEnvVar::is_remote_source) @@ -438,6 +459,7 @@ impl ToolCatalogTransportIdentity { env_vars, cwd, &config.environment_id, + connection_identity.map(|(_, mode, _)| mode.preferred_protocol_version()), client_elicitation_capability, client_mcp_extensions.iter().collect::>(), )) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a61f58b8eaf9..7b101bd2616c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1143,6 +1143,9 @@ "mcp_2026_07_28": { "type": "boolean" }, + "mcp_connection_sharing": { + "type": "boolean" + }, "mcp_oauth_refresh_coordination": { "type": "boolean" }, @@ -7751,6 +7754,9 @@ "mcp_2026_07_28": { "type": "boolean" }, + "mcp_connection_sharing": { + "type": "boolean" + }, "mcp_oauth_refresh_coordination": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index dc57ca9216fc..2fd34d2f0ccd 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -124,6 +124,7 @@ impl ManagedFeatures { let mut values = self.get().clone(); for feature in [ Feature::EnableMcpApps, + Feature::McpConnectionSharing, Feature::SecretAuthStorage, Feature::McpOAuthRefreshCoordination, ] { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1f192747a4e9..e30c8db539a1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1957,6 +1957,7 @@ impl Config { Vec::new() }, protocol_mode: self.mcp_protocol_mode(), + discover_local_stdio: self.features.enabled(Feature::McpConnectionSharing), host_owned_apps_protocol_mode: if self.features.enabled(Feature::CodexAppsMcp20260728) { McpProtocolMode::V20260728 } else { diff --git a/codex-rs/core/src/config/runtime_refresh.rs b/codex-rs/core/src/config/runtime_refresh.rs index a57a5d5aa9e2..a8b61d35987c 100644 --- a/codex-rs/core/src/config/runtime_refresh.rs +++ b/codex-rs/core/src/config/runtime_refresh.rs @@ -67,6 +67,7 @@ impl Config { // These operational controls accept host defaults beneath explicit layer values. // Enterprise opt-in always comes from trusted layers instead. for feature in [ + Feature::McpConnectionSharing, Feature::SecretAuthStorage, Feature::McpOAuthRefreshCoordination, ] { diff --git a/codex-rs/core/tests/common/process.rs b/codex-rs/core/tests/common/process.rs index 82e42aac95e1..710164d7c15b 100644 --- a/codex-rs/core/tests/common/process.rs +++ b/codex-rs/core/tests/common/process.rs @@ -40,7 +40,8 @@ async fn wait_for_process_exit_inner(pid: String) -> anyhow::Result<()> { pub async fn wait_for_process_exit(pid: &str) -> anyhow::Result<()> { let pid = pid.to_string(); - tokio::time::timeout(Duration::from_secs(2), wait_for_process_exit_inner(pid)) + // A stdio process group can keep its leader's PID for the two-second shutdown grace. + tokio::time::timeout(Duration::from_secs(5), wait_for_process_exit_inner(pid)) .await .context("timed out waiting for process to exit")??; diff --git a/codex-rs/core/tests/suite/mcp_tool_cache.rs b/codex-rs/core/tests/suite/mcp_tool_cache.rs index 602252bb8c24..0a0440bc54b7 100644 --- a/codex-rs/core/tests/suite/mcp_tool_cache.rs +++ b/codex-rs/core/tests/suite/mcp_tool_cache.rs @@ -19,6 +19,7 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::McpServerContribution; use codex_extension_api::McpServerContributionContext; use codex_extension_api::McpServerContributor; +use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::mcp::McpServerConnectionStatus; use codex_protocol::models::PermissionProfile; @@ -700,6 +701,11 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow /*sandbox*/ None, ) .await?; + // The subagent can reuse the parent's legacy catalog even when discovery is enabled. + let mut subagent_config = fixture.config.clone(); + subagent_config + .features + .enable(Feature::McpConnectionSharing)?; let NewThread { thread: second_thread, .. @@ -713,7 +719,7 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow agent_nickname: None, agent_role: None, })), - ..StartThreadOptions::new(fixture.config.clone()) + ..StartThreadOptions::new(subagent_config) }) .await?; second_thread.submit(Op::Interrupt).await?; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 4a6595122dd3..5fd61d1445d4 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -269,6 +269,8 @@ pub enum Feature { Mcp20260728, /// Honor ordinary MCP server tool-list notifications within a turn. McpToolListChanged, + /// Discover the MCP protocol for eligible local stdio servers. + McpConnectionSharing, /// Enable MCP protocol version 2026-07-28 for the host-owned Codex Apps server. CodexAppsMcp20260728, /// Let RMCP coordinate OAuth refresh through the configured credential store. @@ -1634,6 +1636,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::McpConnectionSharing, + key: "mcp_connection_sharing", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::CodexAppsMcp20260728, key: "codex_apps_mcp_2026_07_28", diff --git a/codex-rs/rmcp-client/src/bin/test_mcp_2026_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_mcp_2026_stdio_server.rs index 3f6afd99f369..a1293e55a7c0 100644 --- a/codex-rs/rmcp-client/src/bin/test_mcp_2026_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_mcp_2026_stdio_server.rs @@ -2,6 +2,7 @@ use std::io; use std::io::BufRead; use std::io::Write; +use anyhow::Context; use anyhow::Result; use serde_json::Value; use serde_json::json; @@ -41,7 +42,18 @@ fn main() -> Result<()> { let mode = std::env::args().nth(1).unwrap_or_default(); assert!(matches!( mode.as_str(), - "modern" | "legacy" | "legacy-fallback" | "oversized-stdout" | "oversized-stdout-legacy" + "modern" + | "modern-late" + | "legacy" + | "legacy-fallback" + | "legacy-reject-and-exit" + | "legacy-init-first" + | "legacy-reject-late-initialize" + | "legacy-late" + | "legacy-silent" + | "legacy-versions" + | "oversized-stdout" + | "oversized-stdout-legacy" )); assert!(std::env::var_os("CODEX_MCP_PROTOCOL_VERSION").is_none()); let is_legacy = mode.starts_with("legacy") || mode.ends_with("-legacy"); @@ -49,8 +61,9 @@ fn main() -> Result<()> { let mut stdout = io::stdout().lock(); let mut initialized = false; let mut pending_legacy_call = None; + let mut late_discovery_id = None; - for line in io::stdin().lock().lines() { + for (index, line) in io::stdin().lock().lines().enumerate() { let message: Value = serde_json::from_str(&line?)?; let Some(method) = message.get("method").and_then(Value::as_str) else { let pending_call = pending_legacy_call @@ -70,9 +83,60 @@ fn main() -> Result<()> { continue; }; + // Some older helpers assume their first request is initialize without inspecting its method. + let method = if mode == "legacy-init-first" && !initialized { + "initialize" + } else { + method + }; match method { + // Some legacy stdio servers exit if their first message is not initialize. + "server/discover" if mode == "legacy" => { + // Some wrappers print a banner before starting the protocol. + writeln!(stdout, "starting server")?; + write_message( + &mut stdout, + json!({"jsonrpc": "2.0", "id": "startup", "method": "ping"}), + )?; + return Ok(()); + } + "server/discover" if mode == "legacy-late" => { + late_discovery_id = Some(message["id"].clone()); + } + // The first helper makes its version offer only after it receives initialize. + "server/discover" + if mode == "modern-late" + && !std::path::Path::new("retry-modern").try_exists()? => {} + "initialize" if mode == "modern-late" => { + std::fs::write("retry-modern", [])?; + write_message( + &mut stdout, + json!({ + "jsonrpc": "2.0", "id": null, + "error": {"code": -32022, "message": "unsupported protocol", "data": {"supported": [MODERN_VERSION]}}, + }), + )?; + return Ok(()); + } + "server/discover" if mode == "legacy-silent" => { + write_message( + &mut stdout, + json!({ + "jsonrpc": "2.0", "method": "notifications/message", + "params": {"level": "info", "data": "starting server"}, + }), + )?; + } + "server/discover" if mode == "legacy-versions" => { + write_message( + &mut stdout, + json!({ + "jsonrpc": "2.0", "id": message["id"], + "error": {"code": -32022, "message": "unsupported protocol", "data": {"supported": [LEGACY_VERSION]}}, + }), + )?; + } "server/discover" if is_legacy => { - assert_eq!(mode, "legacy-fallback"); write_message( &mut stdout, json!({ @@ -81,6 +145,9 @@ fn main() -> Result<()> { "error": {"code": -32601, "message": "method not found"}, }), )?; + if mode == "legacy-reject-and-exit" { + return Ok(()); + } } "server/discover" => { assert!(!initialized); @@ -107,9 +174,28 @@ fn main() -> Result<()> { }), )?; } + "initialize" if mode == "legacy-reject-late-initialize" && index != 0 => { + write_message( + &mut stdout, + json!({"jsonrpc": "2.0", "id": message["id"], "error": { + "code": -32600, "message": "initialize must be the first request" + }}), + )?; + } "initialize" => { assert!(is_legacy); - assert_eq!(message["params"]["protocolVersion"], LEGACY_VERSION); + if mode == "legacy-late" { + let id = late_discovery_id + .take() + .context("initialize must follow discover on the same pipe")?; + write_message( + &mut stdout, + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not found"}}), + )?; + } + if message["method"] == "initialize" { + assert_eq!(message["params"]["protocolVersion"], LEGACY_VERSION); + } initialized = true; write_message( &mut stdout, @@ -130,6 +216,9 @@ fn main() -> Result<()> { if !is_legacy { assert_modern_metadata(&message); } + if mode == "oversized-stdout-legacy" { + write!(stdout, "\u{feff}")?; + } let description = if oversized { "x".repeat(MAX_MESSAGE_BYTES + 1) } else { diff --git a/codex-rs/rmcp-client/src/bounded_stdio_transport.rs b/codex-rs/rmcp-client/src/bounded_stdio_transport.rs index fdde9f698ef2..dc065995f50b 100644 --- a/codex-rs/rmcp-client/src/bounded_stdio_transport.rs +++ b/codex-rs/rmcp-client/src/bounded_stdio_transport.rs @@ -10,6 +10,8 @@ use rmcp::service::RxJsonRpcMessage; use rmcp::service::TxJsonRpcMessage; use rmcp::transport::Transport; use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncRead; +use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::process::ChildStdin; @@ -38,6 +40,20 @@ impl BoundedStdioTransport { } } + /// Pass both a partial line and buffered read-ahead to the SDK's legacy codec. + pub(super) fn into_legacy_pipes( + self, + ) -> io::Result<(Box, ChildStdin)> { + let stdin = self + .stdin + .try_lock() + .map_err(io::Error::other)? + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "MCP stdin closed"))?; + let stdout = io::Cursor::new(self.pending_line).chain(self.stdout); + Ok((Box::new(stdout), stdin)) + } + async fn receive_message(&mut self) -> Option> { loop { let bytes = match self.stdout.fill_buf().await { diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs index d6c6b02c3ca7..f2e2cf55a30e 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter.rs @@ -898,11 +898,11 @@ pub(super) fn legacy_discovery_fallback_response( } fn only_known_legacy_protocol_versions(versions: &[ProtocolVersion]) -> bool { - !versions.is_empty() - && versions.iter().all(|version| { - ProtocolVersion::KNOWN_VERSIONS.contains(version) - && version < &ProtocolVersion::V_2026_07_28 - }) + !versions.is_empty() && versions.iter().all(is_known_legacy_protocol_version) +} + +pub(super) fn is_known_legacy_protocol_version(version: &ProtocolVersion) -> bool { + ProtocolVersion::KNOWN_VERSIONS.contains(version) && version < &ProtocolVersion::V_2026_07_28 } fn explicitly_rejects_modern_protocol_version(message: &str) -> bool { diff --git a/codex-rs/rmcp-client/src/local_stdio_transport.rs b/codex-rs/rmcp-client/src/local_stdio_transport.rs index 381aad1a9971..2e908278dcf1 100644 --- a/codex-rs/rmcp-client/src/local_stdio_transport.rs +++ b/codex-rs/rmcp-client/src/local_stdio_transport.rs @@ -6,40 +6,150 @@ use std::future::Future; use std::io; use std::time::Duration; -use codex_utils_pty::Command; use futures::FutureExt; +use rmcp::model::ClientRequest; +use rmcp::model::ErrorCode; +use rmcp::model::JsonRpcMessage; +use rmcp::model::RequestId; +use rmcp::model::ServerResult; use rmcp::service::RoleClient; use rmcp::service::RxJsonRpcMessage; use rmcp::service::TxJsonRpcMessage; use rmcp::transport::Transport; use rmcp::transport::async_rw::AsyncRwTransport; +use tokio::io::AsyncRead; use tokio::process::ChildStderr; use tokio::process::ChildStdin; -use tokio::process::ChildStdout; +use tokio::sync::oneshot; use crate::bounded_stdio_transport::BoundedStdioTransport; use crate::protocol_mode::McpProtocolMode; use codex_utils_pty::Child; pub(super) struct LocalStdioTransport { - child: Box, + child: Option>, transport: StdioTransport, + discovery: Option, +} + +pub(super) enum DiscoveryOutcome { + Reply, + RetryLegacy, + Late { + reply: Box>, + after_reoffer: bool, + }, +} + +struct Discovery { + finished: Option>, + outcome: Option, + retry_at: Option, + probe_id: Option, + initialize_id: Option, +} + +impl Drop for Discovery { + fn drop(&mut self) { + // A version re-offer can supersede an earlier reply. Publish only when startup ends. + if let Some(tx) = self.finished.take() + && let Some(outcome) = self.outcome.take() + { + let _ = tx.send(outcome); + } + } +} + +impl Discovery { + /// Track discovery replies and skip messages that do not answer the active handshake. + fn handle_reply(&mut self, message: &mut Option>) -> bool { + let Some(reply) = message.as_ref() else { + self.outcome.get_or_insert(DiscoveryOutcome::RetryLegacy); + return false; + }; + let (id, modern) = match reply { + JsonRpcMessage::Response(response) => ( + Some(&response.id), + matches!(response.result, ServerResult::DiscoverResult(_)), + ), + JsonRpcMessage::Error(error) => ( + error.id.as_ref(), + matches!( + error.error.code, + ErrorCode::UNSUPPORTED_PROTOCOL_VERSION + | ErrorCode::HEADER_MISMATCH + | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY + ), + ), + _ => return false, + }; + let matches_id = |sent: &RequestId| match (sent, id) { + (RequestId::Number(sent), Some(RequestId::String(received))) => { + received.parse::().ok() == Some(*sent) + } + (_, Some(received)) => sent == received, + (_, None) => false, + }; + let legacy = self.initialize_id.is_some(); + let matches_probe = self.probe_id.as_ref().is_some_and(matches_id); + let idless = self.probe_id.is_some() && id.is_none(); + if legacy + && (matches_probe || idless) + && matches!(reply, JsonRpcMessage::Error(error) if error.error.code == ErrorCode::METHOD_NOT_FOUND) + { + // If initialize also fails, try it as the first request on a fresh helper. + self.outcome.get_or_insert(DiscoveryOutcome::RetryLegacy); + } + // With one request rmcp accepts an ID-less error. With two, only a modern + // protocol error can be classified as a discovery reply without an ID. + if matches_probe || (idless && (!legacy || modern)) { + self.probe_id = None; + if legacy { + // Auto sent initialize before receiving the reply to its earlier probe. + if !modern && matches!(reply, JsonRpcMessage::Error(_)) { + return true; + } + // Stop the obsolete initialize and let the caller classify the reply. + self.outcome = message.take().map(|reply| DiscoveryOutcome::Late { + reply: Box::new(reply), + after_reoffer: matches!(self.outcome, Some(DiscoveryOutcome::Reply)), + }); + return false; + } + if modern { + self.retry_at = None; + self.outcome = Some(DiscoveryOutcome::Reply); + } + } else if self.probe_id.is_some() + && id.is_some() + && !self.initialize_id.as_ref().is_some_and(matches_id) + { + // rmcp would abort the handshake before reading the reply to discover or initialize. + return true; + } else if legacy && matches!(reply, JsonRpcMessage::Response(_)) { + // Once initialize wins, rmcp owns the session and can ignore the obsolete probe. + self.probe_id = None; + self.retry_at = None; + self.outcome = Some(DiscoveryOutcome::Reply); + } + false + } } enum StdioTransport { /// Preserve rmcp's existing framing for servers using the initialize handshake. - Legacy(AsyncRwTransport), + Legacy(AsyncRwTransport, ChildStdin>), /// Bound frames and skip messages unknown to the client during 2026-07-28 discovery. - V20260728(BoundedStdioTransport), + Modern(BoundedStdioTransport), + Closed, } impl LocalStdioTransport { - pub(super) fn spawn( - command: Command, + pub(super) fn new( + mut child: Child, program_name: String, protocol_mode: McpProtocolMode, ) -> io::Result<(Self, Option)> { - let mut child = command.spawn()?; let stdin = child .stdin .take() @@ -50,22 +160,46 @@ impl LocalStdioTransport { .ok_or_else(|| io::Error::other("MCP server stdout was not piped"))?; let stderr = child.stderr.take(); let transport = match protocol_mode { - McpProtocolMode::Legacy => StdioTransport::Legacy(AsyncRwTransport::new(stdout, stdin)), - McpProtocolMode::V20260728 => { - StdioTransport::V20260728(BoundedStdioTransport::new(stdin, stdout, program_name)) + McpProtocolMode::Legacy => { + StdioTransport::Legacy(AsyncRwTransport::new(Box::new(stdout), stdin)) + } + McpProtocolMode::V20260728 | McpProtocolMode::DiscoverLocalStdio => { + StdioTransport::Modern(BoundedStdioTransport::new(stdin, stdout, program_name)) } }; Ok(( Self { - child: Box::new(child), + child: Some(Box::new(child)), transport, + discovery: None, }, stderr, )) } - pub(super) fn id(&self) -> Option { - self.child.id() + pub(super) fn watch_discovery( + &mut self, + timeout: Option, + ) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.discovery = Some(Discovery { + finished: Some(tx), + outcome: None, + // Aim for three seconds of discovery. Reserve at least a quarter of short + // timeouts and half of longer ones for a fresh initialize. + retry_at: timeout.and_then(|limit| { + let probe_timeout = Duration::from_secs(3).clamp(limit / 2, limit - limit / 4); + tokio::time::Instant::now().checked_add(probe_timeout) + }), + probe_id: None, + initialize_id: None, + }); + rx + } + + #[cfg(unix)] + pub(super) fn take_child(&mut self) -> Option> { + self.child.take() } } @@ -76,27 +210,87 @@ impl Transport for LocalStdioTransport { &mut self, item: TxJsonRpcMessage, ) -> impl Future> + Send + 'static { + if self.discovery.as_ref().is_some_and(|probe| { + probe + .finished + .as_ref() + .is_none_or(oneshot::Sender::is_closed) + }) { + self.discovery = None; + } + if let Some(probe) = &mut self.discovery + && let JsonRpcMessage::Request(request) = &item + && matches!(request.request, ClientRequest::DiscoverRequest(_)) + { + probe.probe_id = Some(request.id.clone()); + } + if let Some(probe) = &mut self.discovery + && let JsonRpcMessage::Request(request) = &item + && matches!(request.request, ClientRequest::InitializeRequest(_)) + && matches!(self.transport, StdioTransport::Modern(_)) + { + let StdioTransport::Modern(transport) = + std::mem::replace(&mut self.transport, StdioTransport::Closed) + else { + unreachable!(); + }; + match transport.into_legacy_pipes() { + Ok((stdout, stdin)) => { + probe.initialize_id = Some(request.id.clone()); + self.transport = StdioTransport::Legacy(AsyncRwTransport::new(stdout, stdin)); + } + Err(error) => return futures::future::err(error).boxed(), + } + } match &mut self.transport { StdioTransport::Legacy(transport) => transport.send(item).boxed(), - StdioTransport::V20260728(transport) => transport.send(item).boxed(), + StdioTransport::Modern(transport) => transport.send(item).boxed(), + StdioTransport::Closed => futures::future::err(io::Error::new( + io::ErrorKind::BrokenPipe, + "MCP stdin closed", + )) + .boxed(), } } - fn receive(&mut self) -> impl Future>> + Send { - match &mut self.transport { - StdioTransport::Legacy(transport) => transport.receive().boxed(), - StdioTransport::V20260728(transport) => transport.receive().boxed(), + async fn receive(&mut self) -> Option> { + loop { + let receive = async { + match &mut self.transport { + StdioTransport::Legacy(transport) => transport.receive().await, + StdioTransport::Modern(transport) => transport.receive().await, + StdioTransport::Closed => None, + } + }; + let mut message = match self.discovery.as_ref().and_then(|probe| probe.retry_at) { + Some(retry_at) => tokio::time::timeout_at(retry_at, receive) + .await + .unwrap_or_default(), + None => receive.await, + }; + if self + .discovery + .as_mut() + .is_some_and(|probe| probe.handle_reply(&mut message)) + { + continue; + } + return message; } } async fn close(&mut self) -> io::Result<()> { match &mut self.transport { StdioTransport::Legacy(transport) => transport.close().await?, - StdioTransport::V20260728(transport) => transport.close().await?, + StdioTransport::Modern(transport) => transport.close().await?, + StdioTransport::Closed => {} } - match tokio::time::timeout(Duration::from_secs(3), self.child.wait()).await { + let Some(child) = &mut self.child else { + return Ok(()); + }; + match tokio::time::timeout(Duration::from_secs(3), child.wait()).await { Ok(status) => status.map(|_| ()), - Err(_) => self.child.kill().await, + Err(_) => child.kill().await, } } } diff --git a/codex-rs/rmcp-client/src/protocol_mode.rs b/codex-rs/rmcp-client/src/protocol_mode.rs index 656ba8f6e97e..35d673a86cb9 100644 --- a/codex-rs/rmcp-client/src/protocol_mode.rs +++ b/codex-rs/rmcp-client/src/protocol_mode.rs @@ -12,6 +12,8 @@ pub enum McpProtocolMode { Legacy, /// Allow the MCP 2026-07-28 discovery and request lifecycle. V20260728, + /// Probe an eligible local stdio server even when its launch config omits a version. + DiscoverLocalStdio, } impl McpProtocolMode { @@ -19,27 +21,27 @@ impl McpProtocolMode { pub fn preferred_protocol_version(self) -> ProtocolVersion { match self { Self::Legacy => ProtocolVersion::V_2025_06_18, - Self::V20260728 => ProtocolVersion::V_2026_07_28, + Self::V20260728 | Self::DiscoverLocalStdio => ProtocolVersion::V_2026_07_28, } } pub(crate) fn client_lifecycle(self) -> ClientLifecycleMode { match self { Self::Legacy => ClientLifecycleMode::Initialize, - Self::V20260728 => ClientLifecycleMode::Auto { + Self::V20260728 | Self::DiscoverLocalStdio => ClientLifecycleMode::Auto { preferred_versions: vec![ProtocolVersion::V_2026_07_28], legacy_version: Some(ProtocolVersion::V_2025_06_18), }, } } - pub(crate) fn stdio_mode(self, requested_version: Option<&OsStr>) -> io::Result { + /// Selects the stdio handshake from the client policy and the server's declared version. + pub fn stdio_mode(self, requested_version: Option<&OsStr>) -> io::Result { match (self, requested_version) { (Self::Legacy, _) => Ok(Self::Legacy), - (_, None) => Ok(Self::Legacy), - (Self::V20260728, Some(version)) if version == OsStr::new("2026-07-28") => { - Ok(Self::V20260728) - } + (Self::DiscoverLocalStdio, None) => Ok(Self::DiscoverLocalStdio), + (Self::V20260728, None) => Ok(Self::Legacy), + (_, Some(version)) if version == OsStr::new("2026-07-28") => Ok(Self::V20260728), (_, Some(version)) => Err(io::Error::new( io::ErrorKind::InvalidInput, format!( diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index ed3a144d5df4..46b86cac6b5b 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -103,14 +103,17 @@ use crate::ordered_byte_transport::OrderedByteTransport; use crate::protocol_mode::McpProtocolMode; use crate::request_cancellation::RequestCancellation; use crate::startup_error::is_authentication_required_error; +use crate::stdio_server_launcher::StdioProcessSlot; use crate::stdio_server_launcher::StdioServerCommand; use crate::stdio_server_launcher::StdioServerLauncher; -use crate::stdio_server_launcher::StdioServerProcessHandle; use crate::stdio_server_launcher::StdioServerTransport; +use crate::stdio_server_launcher::StdioTermination; use crate::tool_list_changes::track_tool_list_changes; use crate::utils::build_default_headers; use codex_config::types::OAuthCredentialsStoreMode; +#[path = "stdio_handshake.rs"] +mod stdio_handshake; #[path = "streamable_http_retry.rs"] mod streamable_http_retry; @@ -410,8 +413,9 @@ pub struct CancellableEventStreamRequest { /// MCP client implemented on top of the official `rmcp` SDK. /// https://github.com/modelcontextprotocol/rust-sdk pub struct RmcpClient { + // Drop our interest in retrying before the SDK transport uses its ordinary shutdown grace. + stdio_process: Option, state: Mutex, - stdio_process: Option, transport_recipe: TransportRecipe, protocol_mode: McpProtocolMode, requires_read_only_tools: bool, @@ -516,7 +520,7 @@ impl RmcpClient { ) -> io::Result { let requested_stdio_version = match protocol_mode { McpProtocolMode::Legacy => None, - McpProtocolMode::V20260728 => env + McpProtocolMode::V20260728 | McpProtocolMode::DiscoverLocalStdio => env .as_mut() .and_then(|env| env.remove(OsStr::new("CODEX_MCP_PROTOCOL_VERSION"))), }; @@ -536,7 +540,7 @@ impl RmcpClient { .await .map_err(io::Error::other)?; let stdio_process = match &transport { - PendingTransport::Stdio { transport } => Some(transport.process_handle()), + PendingTransport::Stdio { transport } => Some(transport.process_slot()), PendingTransport::InProcess { .. } | PendingTransport::StreamableHttp { .. } | PendingTransport::StreamableHttpWithOAuth { .. } @@ -897,7 +901,7 @@ impl RmcpClient { params.meta = crate::trace_context::with_current_trace(params.meta); self.refresh_oauth_if_needed().await?; let cancellation = self.request_cancellation(); - let requested_modern = self.protocol_mode == McpProtocolMode::V20260728; + let requested_modern = self.protocol_mode != McpProtocolMode::Legacy; let result = self .run_service_operation("resources/read", timeout, move |service| { let params = params.clone(); @@ -1062,7 +1066,7 @@ impl RmcpClient { }; let mut rmcp_params = CallToolRequestParams::new(name); rmcp_params.arguments = arguments; - let requested_modern = self.protocol_mode == McpProtocolMode::V20260728; + let requested_modern = self.protocol_mode != McpProtocolMode::Legacy; let meta_for_session = &meta_for_session; let pause_state = if send_elicitation.is_some() { ElicitationPauseState::new() @@ -1309,18 +1313,34 @@ impl RmcpClient { /// Stop the MCP transport and any stdio server process owned by this client. pub async fn shutdown(&self) { + if let Err(error) = self.shutdown_for(StdioTermination::Shutdown).await { + warn!("failed to terminate MCP stdio server process: {error}"); + } + } + + /// Wait for a failed discovery process to release resources needed by a subsequent launch. + pub async fn shutdown_before_retry(&self) -> io::Result<()> { + self.shutdown_for(StdioTermination::Restart).await + } + + async fn shutdown_for(&self, reason: StdioTermination) -> io::Result<()> { let previous_state = { let mut guard = self.state.lock().await; std::mem::replace(&mut *guard, ClientState::Closed) }; - if let Some(process) = &self.stdio_process - && let Err(error) = process.terminate().await - { - warn!("failed to terminate MCP stdio server process: {error}"); + let process = match &self.stdio_process { + Some(slot) => Some(slot.lock().await.close()), + None => None, + }; + if matches!(reason, StdioTermination::Restart) { + // Let the helper see EOF before escalating to process-group termination. + drop(previous_state); } - - drop(previous_state); + if let Some(process) = process { + process.terminate(reason).await?; + } + Ok(()) } /// This should be called after every tool call so that if a given tool call triggered @@ -1539,12 +1559,18 @@ impl RmcpClient { )> { // Request IDs and remembered cancellations belong to this connection, including // when a failed initialization or expired HTTP session creates a new transport. - let send_elicitation = Arc::clone(&initialize_context.send_elicitation); - let client_service = ElicitationClientService::new( - initialize_context.client_info.clone(), - Box::new(move |id, request| send_elicitation(id, request)), - self.elicitation_pause_state.clone(), - ); + let client_service = || { + let send_elicitation = Arc::clone(&initialize_context.send_elicitation); + let mut info = initialize_context.client_info.clone(); + if self.protocol_mode == McpProtocolMode::DiscoverLocalStdio { + info.protocol_version = McpProtocolMode::Legacy.preferred_protocol_version(); + } + ElicitationClientService::new( + info, + Box::new(move |id, request| send_elicitation(id, request)), + self.elicitation_pause_state.clone(), + ) + }; let _initialize_deadline = match &self.transport_recipe { TransportRecipe::StreamableHttp { initialize_deadline, @@ -1561,9 +1587,21 @@ impl RmcpClient { TransportRecipe::InProcess { .. } | TransportRecipe::Stdio { .. } => None, }; let lifecycle = self.protocol_mode.client_lifecycle(); + let connect_stdio = |transport: StdioServerTransport, lifecycle| { + client_service() + .serve_with_lifecycle( + track_tool_list_changes( + OrderedByteTransport::new(transport), + Arc::clone(&self.tool_list_revision), + ), + lifecycle, + ) + .boxed() + }; + let mut discovery = None; let (transport, oauth_runtime) = match pending_transport { PendingTransport::InProcess { transport } => ( - client_service + client_service() .serve_with_lifecycle( track_tool_list_changes( OrderedByteTransport::new( @@ -1576,20 +1614,17 @@ impl RmcpClient { .boxed(), None, ), - PendingTransport::Stdio { transport } => ( - client_service - .serve_with_lifecycle( - track_tool_list_changes( - OrderedByteTransport::new(*transport), - Arc::clone(&self.tool_list_revision), - ), - lifecycle, - ) - .boxed(), - None, - ), + PendingTransport::Stdio { mut transport } => { + if transport.protocol_mode() == McpProtocolMode::DiscoverLocalStdio { + discovery = transport + .watch_discovery(timeout) + .map(|reply| (reply, transport.process_ref())); + } + let lifecycle = transport.protocol_mode().client_lifecycle(); + (connect_stdio(*transport, lifecycle), None) + } PendingTransport::StreamableHttp { transport } => ( - client_service + client_service() .serve_with_lifecycle( track_tool_list_changes( capture_event_notifications(transport), @@ -1604,7 +1639,7 @@ impl RmcpClient { transport, oauth_runtime, } => ( - client_service + client_service() .serve_with_lifecycle( track_tool_list_changes(transport, Arc::clone(&self.tool_list_revision)), lifecycle, @@ -1613,7 +1648,7 @@ impl RmcpClient { Some(oauth_runtime), ), PendingTransport::StreamableHttpWithAccessTokenOnly { transport } => ( - client_service + client_service() .serve_with_lifecycle( track_tool_list_changes(transport, Arc::clone(&self.tool_list_revision)), lifecycle, @@ -1623,18 +1658,38 @@ impl RmcpClient { ), }; + let handshake = async { + let mut result = transport.await; + if let (Some((mut reply, process)), Err(error)) = (discovery, &result) + && let Some(mode) = stdio_handshake::restart_mode(error, reply.try_recv().ok())? + { + process + .terminate(StdioTermination::Restart) + .await + .context("stop stdio server before handshake restart")?; + let fresh = self.launch_stdio_replacement(mode).await?; + // A confirmed modern helper doesn't need another legacy probe timer. + // Windows can still select initialize if it could not contain the new process. + let lifecycle = match fresh.protocol_mode() { + McpProtocolMode::DiscoverLocalStdio => { + rmcp::service::ClientLifecycleMode::Discover { + preferred_versions: vec![ProtocolVersion::V_2026_07_28], + } + } + mode => mode.client_lifecycle(), + }; + result = connect_stdio(fresh, lifecycle).await; + } + result.map_err(|source| HandshakeError { source }.into()) + }; let service_result = match timeout { - Some(duration) => match time::timeout(duration, transport).await { - Ok(result) => { - result.map_err(|source| anyhow::Error::from(HandshakeError { source })) - } - Err(_elapsed) => Err(anyhow!( - "timed out handshaking with MCP server after {duration:?}" - )), - }, - None => transport + Some(duration) => time::timeout(duration, handshake) .await - .map_err(|source| anyhow::Error::from(HandshakeError { source })), + .with_context(|| { + format!("timed out handshaking with MCP server after {duration:?}") + }) + .flatten(), + None => handshake.await, }; let service = match service_result { Ok(service) => service, @@ -1660,6 +1715,23 @@ impl RmcpClient { Ok((Arc::new(service), oauth_runtime)) } + async fn launch_stdio_replacement( + &self, + mode: McpProtocolMode, + ) -> Result { + let TransportRecipe::Stdio { command, launcher } = &self.transport_recipe else { + unreachable!("stdio discovery uses a stdio launch recipe"); + }; + let slot = self + .stdio_process + .as_ref() + .context("missing stdio process")?; + launcher + .launch(command.for_restart(mode, Arc::clone(slot))) + .await + .context("restart stdio server") + } + async fn run_service_operation( &self, label: &str, diff --git a/codex-rs/rmcp-client/src/stdio_handshake.rs b/codex-rs/rmcp-client/src/stdio_handshake.rs new file mode 100644 index 000000000000..edb513c30a1f --- /dev/null +++ b/codex-rs/rmcp-client/src/stdio_handshake.rs @@ -0,0 +1,115 @@ +//! Selects the handshake for a fresh local helper when automatic discovery cannot finish in place. +//! A late discovery response can still negotiate the modern protocol after rmcp sent initialize. + +use std::io; + +use rmcp::model::ErrorCode; +use rmcp::model::ErrorData; +use rmcp::model::JsonRpcMessage; +use rmcp::model::ProtocolVersion; +use rmcp::model::ServerResult; +use rmcp::service::ClientInitializeError; + +use super::HandshakeError; +use crate::McpProtocolMode; +use crate::http_client_adapter::is_known_legacy_protocol_version; +use crate::local_stdio_transport::DiscoveryOutcome; + +pub(super) fn restart_mode( + error: &ClientInitializeError, + discovery: Option, +) -> anyhow::Result> { + let Some(DiscoveryOutcome::Late { + reply, + after_reoffer, + }) = discovery + else { + return Ok(should_restart_legacy(error, discovery).then_some(McpProtocolMode::Legacy)); + }; + match *reply { + JsonRpcMessage::Response(response) => match response.result { + ServerResult::DiscoverResult(result) => { + let supported = result.supported_versions; + restart_for_versions(&supported).map(Some).ok_or_else(|| { + ClientInitializeError::NoCompatibleProtocolVersion { + client_supported: vec![ProtocolVersion::V_2026_07_28], + server_supported: supported, + } + }) + } + ServerResult::InitializeResult(result) + if is_known_legacy_protocol_version(&result.protocol_version) => + { + Ok(Some(McpProtocolMode::Legacy)) + } + result => Err(ClientInitializeError::ExpectedInitResult(Some(result))), + }, + JsonRpcMessage::Error(response) => { + let mode = offered_restart_mode(&response.error); + match mode { + // rmcp permits one re-offer. A fresh discover verifies it even without an ID. + Some(McpProtocolMode::DiscoverLocalStdio) if !after_reoffer => Ok(mode), + // Without an ID the error may have rejected initialize itself. + Some(McpProtocolMode::Legacy) if response.id.is_some() => Ok(mode), + _ => Err(ClientInitializeError::JsonRpcError(response.error)), + } + } + _ => unreachable!("a late discovery reply is a response or error"), + } + .map_err(|source| HandshakeError { source }.into()) +} + +fn offered_restart_mode(error: &ErrorData) -> Option { + if error.code != ErrorCode::UNSUPPORTED_PROTOCOL_VERSION { + return None; + } + let supported = error.data.as_ref()?.get("supported")?; + let versions = serde_json::from_value::>(supported.clone()).ok()?; + restart_for_versions(&versions) +} + +fn restart_for_versions(supported: &[ProtocolVersion]) -> Option { + if supported.contains(&ProtocolVersion::V_2026_07_28) { + Some(McpProtocolMode::DiscoverLocalStdio) + } else if supported.iter().any(is_known_legacy_protocol_version) { + Some(McpProtocolMode::Legacy) + } else { + None + } +} + +fn should_restart_legacy( + error: &ClientInitializeError, + discovery: Option, +) -> bool { + match error { + // A helper that assumes its first request is initialize can answer discover with this. + ClientInitializeError::ExpectedInitResult(Some(ServerResult::InitializeResult(result))) => { + !matches!(discovery, Some(DiscoveryOutcome::Reply)) + && is_known_legacy_protocol_version(&result.protocol_version) + } + ClientInitializeError::NoCompatibleProtocolVersion { + server_supported, .. + } => restart_for_versions(server_supported) == Some(McpProtocolMode::Legacy), + ClientInitializeError::LegacyFallbackFailed { discover, fallback } => { + match fallback.as_ref() { + ClientInitializeError::ConnectionClosed(_) => true, + // A legacy helper can reject initialize if discover already took its first request. + ClientInitializeError::JsonRpcError(_) => matches!( + discover.as_ref(), + ClientInitializeError::JsonRpcError(error) if error.code == ErrorCode::METHOD_NOT_FOUND + ), + ClientInitializeError::TransportError { error, .. } => { + error.error.downcast_ref::().is_some_and(|e| { + matches!( + e.kind(), + io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset + ) + }) + } + _ => false, + } + } + _ => matches!(discovery, Some(DiscoveryOutcome::RetryLegacy)), + } +} diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 92ccfa2107eb..4925929571b5 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -21,6 +21,10 @@ use std::os::windows::io::OwnedHandle; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +#[cfg(unix)] +use std::sync::Mutex; +use std::sync::OnceLock; +use std::sync::Weak; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; #[cfg(unix)] @@ -29,6 +33,7 @@ use std::thread::sleep; use std::thread::spawn; use std::time::Duration; +use crate::local_stdio_transport::DiscoveryOutcome; use anyhow::Result; use anyhow::anyhow; use codex_config::types::McpServerEnvVar; @@ -39,6 +44,8 @@ use codex_exec_server::ExecProcess; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; +#[cfg(unix)] +use codex_utils_pty::Child; use codex_utils_pty::Command; #[cfg(unix)] use codex_utils_pty::DescriptorPolicy; @@ -47,16 +54,23 @@ use codex_utils_pty::ProcessMode; use codex_utils_pty::process_group::kill_process_group; #[cfg(unix)] use codex_utils_pty::process_group::terminate_process_group; +#[cfg(unix)] +use codex_utils_pty::process_group::wait_for_process_group_exit; use futures::FutureExt; use futures::future::BoxFuture; +use futures::future::Shared; use rmcp::service::RoleClient; use rmcp::service::RxJsonRpcMessage; use rmcp::service::TxJsonRpcMessage; use rmcp::transport::Transport; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; +use tokio::runtime::Handle; +use tokio::sync::Mutex as AsyncMutex; +use tokio::sync::oneshot; use tokio::sync::watch; use tokio::time::Instant; +use tokio_util::task::AbortOnDropHandle; use tracing::info; use tracing::warn; @@ -93,6 +107,70 @@ pub struct StdioServerCommand { env_vars: Vec, cwd: Option, protocol_mode: McpProtocolMode, + replacement_slot: Option, +} + +/// A replacement holds the slot from OS spawn through registration. Closing it seals that handoff. +pub(super) type StdioProcessSlot = Arc>; + +pub(super) struct StdioProcessRegistration { + process: StdioProcessRef, + closed: bool, +} + +impl StdioProcessRegistration { + pub(super) fn close(&mut self) -> StdioProcessRef { + self.closed = true; + self.process.clone() + } +} + +type DiscoveryCleanupResult = Shared>>>; + +struct DiscoveryCleanup { + process: Weak, + result: OnceLock, +} + +impl DiscoveryCleanup { + fn start(&self, runtime: &Handle, reason: StdioTermination) -> &DiscoveryCleanupResult { + self.result.get_or_init(|| { + let process = self.process.upgrade(); + // The owner waits for this worker on shutdown. Its grace periods must also advance + // when the caller leaves a current-thread runtime idle. + runtime + .spawn_blocking(move || { + process + .ok_or_else(|| io::Error::other("discovery process already released"))? + .terminate_discovery(reason) + }) + .map(|joined| match joined { + Ok(result) => result.map_err(Arc::new), + Err(error) => Err(Arc::new(io::Error::other(error))), + }) + .boxed() + .shared() + }) + } +} + +#[derive(Clone)] +pub(super) struct StdioProcessRef { + process: Weak, + discovery_cleanup: Option>, +} + +impl StdioProcessRef { + pub(super) async fn terminate(&self, reason: StdioTermination) -> io::Result<()> { + if let Some(cleanup) = &self.discovery_cleanup { + let result = cleanup.start(&Handle::current(), reason); + return result.clone().await.map_err(io::Error::other); + } + if let Some(process) = self.process.upgrade() { + process.terminate().await?; + } + Ok(()) + } } /// Client-side rmcp transport for a launched MCP stdio server. @@ -102,7 +180,9 @@ pub struct StdioServerCommand { /// directly to `rmcp::service::serve_client`. pub struct StdioServerTransport { inner: StdioServerTransportInner, - process: StdioServerProcessHandle, + process: Arc, + discovery_cleanup: Option>, + protocol_mode: McpProtocolMode, } enum StdioServerTransportInner { @@ -137,7 +217,9 @@ impl Transport for StdioServerTransport { } async fn close(&mut self) -> std::result::Result<(), Self::Error> { - self.process.terminate().await?; + if self.discovery_cleanup.is_none() { + self.process.terminate().await?; + } match &mut self.inner { StdioServerTransportInner::Local(transport) => transport.close().await, StdioServerTransportInner::Executor(transport) => transport.close().await, @@ -145,9 +227,48 @@ impl Transport for StdioServerTransport { } } +impl Drop for StdioServerTransport { + fn drop(&mut self) { + // A client or handshake still holding this result may retry after the SDK drops a + // failed transport. Otherwise the process handle runs its ordinary shutdown on drop. + if let Some(cleanup) = &self.discovery_cleanup + && Arc::strong_count(cleanup) > 1 + && !self.process.terminated.load(Ordering::Acquire) + && let Ok(runtime) = Handle::try_current() + { + cleanup.start(&runtime, StdioTermination::Restart); + } + } +} + impl StdioServerTransport { - pub(crate) fn process_handle(&self) -> StdioServerProcessHandle { - self.process.clone() + pub(super) fn protocol_mode(&self) -> McpProtocolMode { + self.protocol_mode + } + + /// Observes the first discovery reply or a clean EOF during local stdio startup. + pub(super) fn watch_discovery( + &mut self, + timeout: Option, + ) -> Option> { + match &mut self.inner { + StdioServerTransportInner::Local(local) => Some(local.watch_discovery(timeout)), + StdioServerTransportInner::Executor(_) => None, + } + } + + pub(super) fn process_ref(&self) -> StdioProcessRef { + StdioProcessRef { + process: Arc::downgrade(&self.process), + discovery_cleanup: self.discovery_cleanup.clone(), + } + } + + pub(super) fn process_slot(&self) -> StdioProcessSlot { + Arc::new(AsyncMutex::new(StdioProcessRegistration { + process: self.process_ref(), + closed: false, + })) } } @@ -169,6 +290,20 @@ impl StdioServerCommand { env_vars, cwd, protocol_mode, + replacement_slot: None, + } + } + + /// Registers the replacement with its original client before the native spawn can finish. + pub(super) fn for_restart( + &self, + protocol_mode: McpProtocolMode, + slot: StdioProcessSlot, + ) -> Self { + Self { + protocol_mode, + replacement_slot: Some(slot), + ..self.clone() } } } @@ -202,11 +337,13 @@ impl StdioServerLauncher for LocalStdioServerLauncher { ) -> BoxFuture<'static, io::Result> { let fallback_cwd = self.fallback_cwd.clone(); async move { - // Keep synchronous program resolution and process creation from blocking the - // caller's startup deadline. - tokio::task::spawn_blocking(move || Self::launch_server(command, fallback_cwd)) - .await - .map_err(io::Error::other)? + // Cancel queued launches when startup is dropped. A worker that already started + // drops its transport and cleans up the child when it finishes. + AbortOnDropHandle::new(tokio::task::spawn_blocking(move || { + Self::launch_server(command, fallback_cwd) + })) + .await + .map_err(io::Error::other)? } .boxed() } @@ -217,30 +354,75 @@ impl StdioServerLauncher for LocalStdioServerLauncher { #[cfg(unix)] const PROCESS_GROUP_TERM_GRACE_PERIOD: Duration = Duration::from_secs(2); +#[cfg(unix)] +const RESTART_DRAIN_GRACE_PERIOD: Duration = Duration::from_millis(250); + // Keep queued stderr diagnostics before closing the reader, even when an // escaped descendant prevents the pipe from reaching EOF. const STDERR_READER_DRAIN_GRACE_PERIOD: Duration = Duration::from_millis(250); #[cfg(unix)] -struct LocalProcessTerminator { - process_group_id: u32, +enum LocalProcessTerminator { + ProcessGroup(u32), + // The SDK may drop the failed transport before the caller can decide whether to restart it. + DiscoveryLeader { + leader: Mutex>>, + runtime: Handle, + }, +} + +#[cfg(unix)] +#[derive(Clone)] +struct KillDiscoveryGroupOnDrop(Arc>>>); + +#[cfg(unix)] +impl KillDiscoveryGroupOnDrop { + fn new(child: Box) -> Self { + Self(Arc::new(Mutex::new(Some(child)))) + } + + fn kill(&self) -> io::Result<()> { + // Runtime teardown must wait for a timer that has already started sending the signal. + let mut leader = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(child) = leader.as_ref() + && let Some(pid) = child.id() + { + kill_process_group_and_warn(pid)?; + } + leader.take(); + Ok(()) + } +} + +#[cfg(unix)] +impl Drop for KillDiscoveryGroupOnDrop { + fn drop(&mut self) { + let _ = self.kill(); + } +} + +#[cfg(unix)] +fn kill_process_group_and_warn(pid: u32) -> io::Result<()> { + kill_process_group(pid) + .inspect_err(|error| warn!("Failed to kill MCP process group {pid}: {error}")) } #[cfg(windows)] enum LocalProcessTerminator { - Job(codex_utils_pty::JobObject), + Job { + job: codex_utils_pty::JobObject, + discovery_root: Option, + }, Process(OwnedHandle), } #[cfg(not(any(unix, windows)))] struct LocalProcessTerminator; -#[derive(Clone)] pub(crate) struct StdioServerProcessHandle { - inner: Arc, -} - -struct StdioServerProcessHandleInner { program_name: String, kind: StdioServerProcessKind, terminated: AtomicBool, @@ -253,6 +435,12 @@ enum StdioServerProcessKind { Executor(Arc), } +#[derive(Clone, Copy)] +pub(super) enum StdioTermination { + Shutdown, + Restart, +} + mod private { pub trait Sealed {} } @@ -271,6 +459,7 @@ impl LocalStdioServerLauncher { env_vars, cwd, protocol_mode, + replacement_slot, } = command; let program_name = program.to_string_lossy().into_owned(); let envs = create_env_for_mcp_server(env, &env_vars).map_err(io::Error::other)?; @@ -306,45 +495,56 @@ impl LocalStdioServerLauncher { } }; - let spawn_transport = |command: Command| -> io::Result<( - StdioServerTransportInner, - Option, - Option, - )> { - let (transport, stderr) = - LocalStdioTransport::spawn(command, program_name.clone(), protocol_mode)?; - let process_id = transport.id(); - Ok(( - StdioServerTransportInner::Local(transport), - stderr, - process_id, - )) - }; - let (transport, stderr, process_id) = spawn_transport(command)?; + // The async caller can be cancelled while this blocking worker is inside spawn. + let slot = replacement_slot.as_ref().map(|slot| slot.blocking_lock()); + if slot.as_ref().is_some_and(|slot| slot.closed) { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "MCP client is shut down", + )); + } + let child = command.spawn()?; #[cfg(windows)] - let (transport, stderr, process_id, job) = match job { - Some(job) => match process_id + let (child, job) = match job { + Some(job) => match child + .id() .ok_or_else(|| io::Error::other("missing suspended MCP server process id")) .and_then(|process_id| job.assign_and_resume_process(process_id)) { - Ok(true) => (transport, stderr, process_id, Some(job)), - Ok(false) => (transport, stderr, process_id, None), + Ok(Some(root)) => (child, Some((job, root))), + Ok(None) => (child, None), Err(error) => { warn!( "Windows MCP process job containment failed; retrying without it: {error}" ); - drop(stderr); - drop(transport); + drop(child); drop(job); - let (transport, stderr, process_id) = spawn_transport(build_command())?; - (transport, stderr, process_id, None) + (build_command().spawn()?, None) } }, - None => (transport, stderr, process_id, None), + None => (child, None), + }; + #[cfg(windows)] + let protocol_mode = if job.is_none() && protocol_mode == McpProtocolMode::DiscoverLocalStdio + { + // A probe can require a restart. Without a job we cannot stop its descendants. + McpProtocolMode::Legacy + } else { + protocol_mode }; + let process_id = child.id(); + // A legacy replacement still belongs to the discovery startup that launched it. + let discovery_owned = (protocol_mode == McpProtocolMode::DiscoverLocalStdio + || replacement_slot.is_some()) + && process_id.is_some(); + let (transport, stderr) = + LocalStdioTransport::new(child, program_name.clone(), protocol_mode)?; #[cfg(windows)] let terminator = match job { - Some(job) => Some(LocalProcessTerminator::Job(job)), + Some((job, root)) => Some(LocalProcessTerminator::Job { + job, + discovery_root: discovery_owned.then_some(root), + }), None => process_id.and_then(|process_id| { match codex_utils_pty::JobObject::open_process_handle(process_id) { Ok(handle) => Some(LocalProcessTerminator::Process(handle)), @@ -355,8 +555,23 @@ impl LocalStdioServerLauncher { } }), }; - #[cfg(not(windows))] - let terminator = process_id.map(LocalProcessTerminator::new); + #[cfg(unix)] + let (transport, terminator) = { + let mut transport = transport; + let terminator = process_id.map(|pid| { + if discovery_owned { + LocalProcessTerminator::DiscoveryLeader { + leader: Mutex::new(transport.take_child()), + runtime: Handle::current(), + } + } else { + LocalProcessTerminator::ProcessGroup(pid) + } + }); + (transport, terminator) + }; + #[cfg(not(any(unix, windows)))] + let terminator = process_id.map(|_| LocalProcessTerminator); let stderr_reader = stderr.map(|stderr| { let program_name = program_name.clone(); let (stop_tx, mut stop_rx) = watch::channel(()); @@ -395,31 +610,77 @@ impl LocalStdioServerLauncher { stop_tx }); let process = StdioServerProcessHandle::local(program_name, terminator, stderr_reader); - - Ok(StdioServerTransport { - inner: transport, + let discovery_cleanup = discovery_owned.then(|| { + Arc::new(DiscoveryCleanup { + process: Arc::downgrade(&process), + result: OnceLock::new(), + }) + }); + let transport = StdioServerTransport { + inner: StdioServerTransportInner::Local(transport), process, - }) + discovery_cleanup, + protocol_mode, + }; + if let Some(mut slot) = slot { + slot.process = transport.process_ref(); + } + + Ok(transport) } } impl LocalProcessTerminator { - #[cfg(not(windows))] - fn new(process_group_id: u32) -> Self { - #[cfg(unix)] - { - Self { process_group_id } - } - #[cfg(not(any(unix, windows)))] - { - let _ = process_group_id; - Self + #[cfg(unix)] + fn terminate_discovery(&self, reason: StdioTermination) -> io::Result<()> { + let Self::DiscoveryLeader { leader, .. } = self else { + return Err(io::Error::other("missing Unix MCP discovery leader")); + }; + let child = leader + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(|| io::Error::other("Unix MCP discovery leader already released"))?; + let pid = child + .id() + .ok_or_else(|| io::Error::other("missing Unix MCP discovery leader PID"))?; + let cleanup = KillDiscoveryGroupOnDrop::new(child); + // A failed transport closed stdin. Let the helper release its endpoint before TERM, + // and reserve its PID through the last group signal. + let term_grace = match reason { + StdioTermination::Restart => { + sleep(RESTART_DRAIN_GRACE_PERIOD); + RESTART_DRAIN_GRACE_PERIOD + } + StdioTermination::Shutdown => PROCESS_GROUP_TERM_GRACE_PERIOD, + }; + if terminate_process_group(pid)? { + // A wrapper may exit before a child handling SIGTERM has released its own endpoint. + wait_for_process_group_exit(pid, term_grace); } + cleanup.kill()?; + wait_for_process_group_exit(pid, Duration::from_secs(1)); + Ok(()) } #[cfg(unix)] fn terminate(&self) { - let process_group_id = self.process_group_id; + let (process_group_id, discovery) = match self { + Self::ProcessGroup(pid) => (*pid, None), + Self::DiscoveryLeader { leader, runtime } => { + let Some(leader) = leader + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + else { + return; + }; + let Some(pid) = leader.id() else { + return; + }; + (pid, Some((runtime, leader))) + } + }; let should_escalate = match terminate_process_group(process_group_id) { Ok(exists) => exists, Err(error) => { @@ -428,19 +689,46 @@ impl LocalProcessTerminator { } }; if should_escalate { + let discovery = discovery.map(|(runtime, leader)| { + let on_timeout = KillDiscoveryGroupOnDrop::new(leader); + let on_runtime_exit = on_timeout.clone(); + let (finished, wait) = oneshot::channel::<()>(); + std::mem::drop(runtime.spawn(async move { + let _ = wait.await; + drop(on_runtime_exit); + })); + (on_timeout, finished) + }); + // A caller can leave a current-thread runtime idle for the entire grace period. spawn(move || { sleep(PROCESS_GROUP_TERM_GRACE_PERIOD); - if let Err(error) = kill_process_group(process_group_id) { - warn!("Failed to kill MCP process group {process_group_id}: {error}"); + if let Some((on_timeout, finished)) = discovery { + drop(on_timeout); + drop(finished); + } else { + let _ = kill_process_group_and_warn(process_group_id); } }); } } + #[cfg(windows)] + fn terminate_discovery(&self, _reason: StdioTermination) -> io::Result<()> { + match self { + Self::Job { + job, + discovery_root: Some(root), + } => job.terminate_and_wait(root, Duration::from_secs(1)), + _ => Err(io::Error::other( + "cannot restart an uncontained Windows MCP server", + )), + } + } + #[cfg(windows)] fn terminate(&self) { let result = match self { - Self::Job(job) => job.terminate(), + Self::Job { job, .. } => job.terminate(), Self::Process(process_handle) => { codex_utils_pty::JobObject::terminate_process_handle(process_handle) } @@ -450,6 +738,11 @@ impl LocalProcessTerminator { } } + #[cfg(not(any(unix, windows)))] + fn terminate_discovery(&self, _reason: StdioTermination) -> io::Result<()> { + Err(io::Error::other("MCP stdio restart is unsupported")) + } + #[cfg(not(any(unix, windows)))] fn terminate(&self) {} } @@ -459,55 +752,71 @@ impl StdioServerProcessHandle { program_name: String, terminator: Option, stderr_reader: Option>, - ) -> Self { - Self { - inner: Arc::new(StdioServerProcessHandleInner { - program_name, - kind: StdioServerProcessKind::Local(terminator), - terminated: AtomicBool::new(false), - stderr_reader, - }), + ) -> Arc { + Arc::new(Self { + program_name, + kind: StdioServerProcessKind::Local(terminator), + terminated: AtomicBool::new(false), + stderr_reader, + }) + } + + pub(crate) fn executor(program_name: String, process: Arc) -> Arc { + Arc::new(Self { + program_name, + kind: StdioServerProcessKind::Executor(process), + terminated: AtomicBool::new(false), + stderr_reader: None, + }) + } + + fn begin_termination(&self) -> bool { + if self.terminated.swap(true, Ordering::AcqRel) { + return false; } + if let Some(stderr_reader) = &self.stderr_reader { + stderr_reader.send_replace(()); + } + true } - pub(crate) fn executor(program_name: String, process: Arc) -> Self { - Self { - inner: Arc::new(StdioServerProcessHandleInner { - program_name, - kind: StdioServerProcessKind::Executor(process), - terminated: AtomicBool::new(false), - stderr_reader: None, - }), + fn terminate_discovery(&self, reason: StdioTermination) -> io::Result<()> { + if !self.begin_termination() { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "MCP stdio server is already shutting down", + )); } + let StdioServerProcessKind::Local(Some(terminator)) = &self.kind else { + return Err(io::Error::other("missing local MCP discovery process")); + }; + // Stop an uncontained replacement, but don't permit a retry on it. + terminator + .terminate_discovery(reason) + .inspect_err(|_| terminator.terminate()) } pub(crate) async fn terminate(&self) -> io::Result<()> { - if self.inner.terminated.swap(true, Ordering::AcqRel) { + if !self.begin_termination() { return Ok(()); } - - let result = match &self.inner.kind { + match &self.kind { StdioServerProcessKind::Local(Some(terminator)) => { terminator.terminate(); Ok(()) } StdioServerProcessKind::Local(None) => Ok(()), - StdioServerProcessKind::Executor(process) => match process.terminate().await { - Ok(()) => Ok(()), - Err(error) => { - self.inner.terminated.store(false, Ordering::Release); - Err(io::Error::other(error)) - } - }, - }; - if let Some(stderr_reader) = &self.inner.stderr_reader { - stderr_reader.send_replace(()); + StdioServerProcessKind::Executor(process) => { + process.terminate().await.map_err(|error| { + self.terminated.store(false, Ordering::Release); + io::Error::other(error) + }) + } } - result } } -impl Drop for StdioServerProcessHandleInner { +impl Drop for StdioServerProcessHandle { fn drop(&mut self) { if self.terminated.swap(true, Ordering::AcqRel) { return; @@ -521,7 +830,7 @@ impl Drop for StdioServerProcessHandleInner { StdioServerProcessKind::Executor(process) => { let process = Arc::clone(process); let program_name = self.program_name.clone(); - let Ok(handle) = tokio::runtime::Handle::try_current() else { + let Ok(handle) = Handle::try_current() else { warn!( "Could not schedule remote MCP server process termination on drop ({}): no Tokio runtime is available", self.program_name @@ -593,7 +902,8 @@ impl ExecutorStdioServerLauncher { env, env_vars, cwd, - protocol_mode: _, + protocol_mode, + replacement_slot: _, } = command; let Some(cwd) = cwd else { return Err(io::Error::other( @@ -644,6 +954,8 @@ impl ExecutorStdioServerLauncher { program_name, )), process, + discovery_cleanup: None, + protocol_mode, }) } diff --git a/codex-rs/rmcp-client/tests/mcp_2026_message_limits.rs b/codex-rs/rmcp-client/tests/mcp_2026_message_limits.rs index 785aa4e1956c..ff28266210b4 100644 --- a/codex-rs/rmcp-client/tests/mcp_2026_message_limits.rs +++ b/codex-rs/rmcp-client/tests/mcp_2026_message_limits.rs @@ -234,13 +234,14 @@ async fn legacy_http_keeps_existing_large_json_response_behavior() -> anyhow::Re async fn modern_local_and_executor_stdio_reject_oversized_lines() -> anyhow::Result<()> { let server = codex_utils_cargo_bin::cargo_bin("test_mcp_2026_stdio_server")?; - for (modern, executor) in [(false, false), (false, true), (true, false), (true, true)] { - let mode = if modern { - McpProtocolMode::V20260728 - } else { - McpProtocolMode::Legacy - }; - let env = modern.then(|| { + for (mode, modern, executor) in [ + (McpProtocolMode::Legacy, false, false), + (McpProtocolMode::Legacy, false, true), + (McpProtocolMode::V20260728, true, false), + (McpProtocolMode::V20260728, true, true), + (McpProtocolMode::DiscoverLocalStdio, false, false), + ] { + let env = (mode == McpProtocolMode::V20260728).then(|| { HashMap::from([( OsString::from("CODEX_MCP_PROTOCOL_VERSION"), OsString::from("2026-07-28"), diff --git a/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs b/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs index 20f1c4eb45c8..42f16ba344fe 100644 --- a/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs +++ b/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs @@ -46,21 +46,21 @@ async fn exercise_stdio_server( OsString::from("2026-07-28"), ); } - let cwd = std::env::current_dir()?; - #[cfg(unix)] let root = tempfile::tempdir()?; + let cwd = if use_executor { + std::env::current_dir()? + } else { + root.path().to_path_buf() + }; #[cfg(unix)] - let (server, cwd) = if use_executor { - (server, cwd) + let server = if use_executor { + server } else { let wrapper = root.path().join("server"); let script = "#!/bin/sh\nprintf '%s' \"$0\" > argv0\nexec \"$MCP_SERVER\" \"$@\"\n"; codex_utils_cargo_bin::write_executable(&wrapper, script)?; env.insert(OsString::from("MCP_SERVER"), server.into_os_string()); - ( - std::path::PathBuf::from("./server"), - root.path().to_path_buf(), - ) + std::path::PathBuf::from("./server") }; let client = RmcpClient::new_stdio_client_with_protocol_mode( server.into(), @@ -86,7 +86,11 @@ async fn exercise_stdio_server( Implementation::new("codex-mcp-client", "0.0.0-test"), ) .with_protocol_version(ProtocolVersion::V_2025_06_18), - Some(Duration::from_secs(5)), + Some(Duration::from_secs(if server_mode.ends_with("-late") { + 30 + } else { + 5 + })), Box::new(move |_request_id, request| { let observed_elicitations = Arc::clone(&observed_elicitations); async move { @@ -240,6 +244,29 @@ async fn rollout_flag_alone_preserves_legacy_stdio_and_sep1034_defaults() -> any .await } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unmarked_stdio_helpers_recover_from_failed_or_late_discovery() -> anyhow::Result<()> { + for mode in [ + "legacy", + "legacy-init-first", + "legacy-reject-late-initialize", + "legacy-reject-and-exit", + "legacy-late", + "legacy-silent", + "legacy-versions", + "modern-late", + ] { + exercise_stdio_server( + mode, + McpProtocolMode::DiscoverLocalStdio, + /*opt_in*/ false, + /*use_executor*/ false, + ) + .await?; + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn modern_stdio_safely_falls_back_to_legacy_elicitation() -> anyhow::Result<()> { exercise_stdio_server( diff --git a/codex-rs/utils/pty/src/process_group.rs b/codex-rs/utils/pty/src/process_group.rs index f403b7e647db..f9e3f4381c36 100644 --- a/codex-rs/utils/pty/src/process_group.rs +++ b/codex-rs/utils/pty/src/process_group.rs @@ -288,6 +288,33 @@ pub fn kill_process_group(_process_group_id: u32) -> io::Result<()> { Ok(()) } +/// Wait up to `grace` for a signaled group's members to release their resources. +/// After that, allow the caller to proceed. An unreaped zombie or a recycled group ID +/// can remain visible after the original processes have released their resources. +#[cfg(unix)] +pub fn wait_for_process_group_exit(process_group_id: u32, grace: std::time::Duration) { + let deadline = std::time::Instant::now() + grace; + loop { + #[cfg(target_os = "macos")] + let exists = signal_process_group_with_member_fallback( + process_group_id, + /*signal*/ 0, + signal_process_group_id, + signal_process_id, + ); + #[cfg(not(target_os = "macos"))] + let exists = signal_process_group_id(process_group_id as libc::pid_t, /*signal*/ 0); + if matches!(exists, Ok(false)) { + break; + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + break; + } + std::thread::sleep(remaining.min(std::time::Duration::from_millis(10))); + } +} + #[cfg(unix)] /// Kill the process group for a tokio child (best-effort). pub fn kill_child_process_group(child: &mut Child) -> io::Result<()> { diff --git a/codex-rs/utils/pty/src/win/job.rs b/codex-rs/utils/pty/src/win/job.rs index f963c6f55fd5..c6b803651d63 100644 --- a/codex-rs/utils/pty/src/win/job.rs +++ b/codex-rs/utils/pty/src/win/job.rs @@ -1,29 +1,44 @@ use filedescriptor::OwnedHandle; +use std::collections::HashMap; use std::io; use std::os::windows::io::AsRawHandle; use std::os::windows::io::FromRawHandle; +use std::os::windows::io::OwnedHandle as ProcessHandle; use std::os::windows::io::RawHandle; use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; use tokio::process::Child; use tokio::process::Command; use winapi::shared::ntdef::NT_SUCCESS; use winapi::shared::ntdef::NTSTATUS; +use winapi::shared::winerror::ERROR_INVALID_PARAMETER; +use winapi::shared::winerror::ERROR_MORE_DATA; +use winapi::shared::winerror::WAIT_TIMEOUT; +use winapi::um::jobapi::IsProcessInJob; use winapi::um::jobapi2::AssignProcessToJobObject; use winapi::um::jobapi2::CreateJobObjectW; +use winapi::um::jobapi2::QueryInformationJobObject; use winapi::um::jobapi2::SetInformationJobObject; use winapi::um::jobapi2::TerminateJobObject; use winapi::um::processthreadsapi::OpenProcess; use winapi::um::processthreadsapi::TerminateProcess; +use winapi::um::synchapi::WaitForSingleObject; use winapi::um::winbase::CREATE_NO_WINDOW; use winapi::um::winbase::CREATE_SUSPENDED; +use winapi::um::winbase::WAIT_OBJECT_0; use winapi::um::winnt::HANDLE; use winapi::um::winnt::JOB_OBJECT_LIMIT_BREAKAWAY_OK; use winapi::um::winnt::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; +use winapi::um::winnt::JOBOBJECT_BASIC_PROCESS_ID_LIST; use winapi::um::winnt::JOBOBJECT_EXTENDED_LIMIT_INFORMATION; +use winapi::um::winnt::JobObjectBasicProcessIdList; use winapi::um::winnt::JobObjectExtendedLimitInformation; +use winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION; use winapi::um::winnt::PROCESS_SET_QUOTA; use winapi::um::winnt::PROCESS_SUSPEND_RESUME; use winapi::um::winnt::PROCESS_TERMINATE; +use winapi::um::winnt::SYNCHRONIZE; #[link(name = "ntdll")] unsafe extern "system" { @@ -39,6 +54,19 @@ pub struct JobObject { preserve_descendants: Mutex, } +// A failed restart must still stop any remaining members. +struct TerminateOnDrop<'a>(Option<&'a JobObject>); + +impl Drop for TerminateOnDrop<'_> { + fn drop(&mut self) { + if let Some(job) = self.0 + && let Err(error) = job.terminate() + { + log::warn!("Failed to terminate Windows job after an interrupted restart: {error}"); + } + } +} + impl JobObject { /// Creates a Job Object configured to terminate all members when its last handle closes. pub fn create() -> io::Result { @@ -67,19 +95,23 @@ impl JobObject { } /// Captures an owned process handle before its numeric identifier can be reused. - pub fn open_process_handle(process_id: u32) -> io::Result { + pub fn open_process_handle(process_id: u32) -> io::Result { + Self::open_process(process_id, PROCESS_TERMINATE) + } + + fn open_process(process_id: u32, access: u32) -> io::Result { let handle = unsafe { - OpenProcess(PROCESS_TERMINATE, /*bInheritHandle*/ 0, process_id) + OpenProcess(access, /*bInheritHandle*/ 0, process_id) }; if handle.is_null() { return Err(io::Error::last_os_error()); } - Ok(unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(handle.cast()) }) + Ok(unsafe { ProcessHandle::from_raw_handle(handle.cast()) }) } /// Terminates the exact process identified by a previously captured handle. - pub fn terminate_process_handle(handle: &std::os::windows::io::OwnedHandle) -> io::Result<()> { + pub fn terminate_process_handle(handle: &ProcessHandle) -> io::Result<()> { let terminated = unsafe { TerminateProcess(handle.as_raw_handle().cast(), /*uExitCode*/ 1) }; @@ -132,22 +164,15 @@ impl JobObject { .kill_on_drop(true); } - /// Assigns and resumes a suspended child, returning whether assignment succeeded. + /// Assigns and resumes a suspended child, returning its handle if containment succeeded. /// /// Nested jobs can reject assignment. Such a child is resumed without /// containment so callers can preserve their existing compatibility fallback. - pub fn assign_and_resume_process(&self, process_id: u32) -> io::Result { - let process = unsafe { - OpenProcess( - PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_SUSPEND_RESUME, - /*bInheritHandle*/ 0, - process_id, - ) - }; - if process.is_null() { - return Err(io::Error::last_os_error()); - } - let process = unsafe { OwnedHandle::from_raw_handle(process.cast()) }; + pub fn assign_and_resume_process(&self, process_id: u32) -> io::Result> { + let process = Self::open_process( + process_id, + PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | SYNCHRONIZE, + )?; let assignment = self.assign_process(process.as_raw_handle()); let status = unsafe { NtResumeProcess(process.as_raw_handle().cast()) }; @@ -161,12 +186,12 @@ impl JobObject { } match assignment { - Ok(()) => Ok(true), + Ok(()) => Ok(Some(process)), Err(error) => { log::warn!( "Windows process job assignment unavailable for pid {process_id}: {error}" ); - Ok(false) + Ok(None) } } } @@ -251,6 +276,113 @@ impl JobObject { Ok(()) } } + + /// Stops a root from [`Self::assign_and_resume_process`] and the remaining members of its job. + pub fn terminate_and_wait(&self, root: &ProcessHandle, grace: Duration) -> io::Result<()> { + let mut cleanup = TerminateOnDrop(Some(self)); + let deadline = Instant::now() + grace; + Self::request_stop(root)?; + Self::wait_for_exit(root, deadline)?; + // TerminateJobObject can empty the job list before members release their handles. + // Stop by process handle, then rescan for children born during the previous pass. + let mut list = vec![0usize; 16]; + let mut stopped = HashMap::new(); + loop { + if Instant::now() >= deadline { + return Err(io::ErrorKind::TimedOut.into()); + } + let pids = self.member_ids(&mut list)?; + if pids.iter().all(|pid| stopped.contains_key(pid)) { + self.terminate()?; + cleanup.0 = None; + return Ok(()); + } + for &pid in pids { + if stopped.contains_key(&pid) { + continue; + } + let member = match Self::open_process( + pid as u32, + PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, + ) { + Ok(member) => member, + Err(error) if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) => { + continue; + } + Err(error) => return Err(error), + }; + // A listed PID can be reused before OpenProcess. Only signal confirmed job members. + let mut in_job = 0; + if unsafe { + IsProcessInJob( + member.as_raw_handle().cast(), + self.handle.as_raw_handle().cast(), + &mut in_job, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if in_job != 0 { + Self::request_stop(&member)?; + stopped.insert(pid, member); + } + } + for member in stopped.values() { + Self::wait_for_exit(member, deadline)?; + } + } + } + + fn member_ids<'a>(&self, list: &'a mut Vec) -> io::Result<&'a [usize]> { + const HEADER_WORDS: usize = + std::mem::offset_of!(JOBOBJECT_BASIC_PROCESS_ID_LIST, ProcessIdList) + / std::mem::size_of::(); + loop { + let listed = unsafe { + QueryInformationJobObject( + self.handle.as_raw_handle().cast(), + JobObjectBasicProcessIdList, + list.as_mut_ptr().cast(), + u32::try_from(std::mem::size_of_val(list.as_slice())) + .map_err(io::Error::other)?, + std::ptr::null_mut(), + ) + }; + if listed != 0 { + let header = unsafe { &*list.as_ptr().cast::() }; + if header.NumberOfProcessIdsInList == header.NumberOfAssignedProcesses { + return Ok(&list[HEADER_WORDS..][..header.NumberOfProcessIdsInList as usize]); + } + } else { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_MORE_DATA as i32) { + return Err(error); + } + } + list.resize(list.len() * 2, 0); + } + } + + fn request_stop(process: &ProcessHandle) -> io::Result<()> { + // Windows also returns access denied when the process has already exited. Its handle + // must still signal before a replacement can start. + match Self::terminate_process_handle(process) { + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => Ok(()), + result => result, + } + } + + fn wait_for_exit(process: &ProcessHandle, deadline: Instant) -> io::Result<()> { + let remaining = deadline.saturating_duration_since(Instant::now()); + // The largest DWORD denotes INFINITE, so cap this bounded wait below it. + let millis = remaining.as_millis().min(u128::from(u32::MAX - 1)) as u32; + match unsafe { WaitForSingleObject(process.as_raw_handle().cast(), millis) } { + WAIT_OBJECT_0 => Ok(()), + WAIT_TIMEOUT => Err(io::ErrorKind::TimedOut.into()), + _ => Err(io::Error::last_os_error()), + } + } } impl AsRawHandle for JobObject { diff --git a/codex-rs/utils/pty/src/windows_tests.rs b/codex-rs/utils/pty/src/windows_tests.rs index 6d6c389b14e8..1a44d06f3f4b 100644 --- a/codex-rs/utils/pty/src/windows_tests.rs +++ b/codex-rs/utils/pty/src/windows_tests.rs @@ -8,6 +8,7 @@ use crate::spawn_pty_process; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::os::windows::io::AsRawHandle; +use std::os::windows::io::BorrowedHandle; use std::os::windows::io::FromRawHandle; use std::os::windows::io::OwnedHandle; use std::os::windows::process::CommandExt; @@ -20,7 +21,10 @@ use tokio::io::BufReader; use tokio::process::Command; use winapi::um::jobapi::IsProcessInJob; use winapi::um::processthreadsapi::OpenProcess; +use winapi::um::synchapi::WaitForSingleObject; +use winapi::um::winbase::WAIT_OBJECT_0; use winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION; +use winapi::um::winnt::SYNCHRONIZE; const READY_MARKER: &str = "__CODEX_CHILD_READY__"; const VALUE_MARKER: &str = "__CODEX_CHILD_VALUE__"; @@ -67,7 +71,10 @@ async fn piped_child_has_no_console_with_or_without_job_containment() -> anyhow: let pid = child .id() .ok_or_else(|| anyhow::anyhow!("missing child pid"))?; - anyhow::ensure!(job.assign_and_resume_process(pid)?, "job assignment failed"); + anyhow::ensure!( + job.assign_and_resume_process(pid)?.is_some(), + "job assignment failed" + ); } child .stdin @@ -289,7 +296,13 @@ async fn contained_spawn_owns_immediate_descendant() -> anyhow::Result<()> { tokio::time::timeout(Duration::from_secs(10), stdout.read_line(&mut child_pid)).await??; let child_pid: u32 = child_pid.trim().parse()?; - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, child_pid) }; + let process = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, + /*bInheritHandle*/ 0, + child_pid, + ) + }; anyhow::ensure!(!process.is_null(), "failed to open immediate child process"); let process = unsafe { OwnedHandle::from_raw_handle(process.cast()) }; let mut in_job = 0; @@ -303,7 +316,15 @@ async fn contained_spawn_owns_immediate_descendant() -> anyhow::Result<()> { anyhow::ensure!(checked != 0, "failed to inspect child Job Object"); anyhow::ensure!(in_job != 0, "immediate child escaped its Job Object"); - job.terminate()?; + let root_handle = root + .raw_handle() + .ok_or_else(|| anyhow::anyhow!("missing contained root handle"))?; + let root_handle = unsafe { BorrowedHandle::borrow_raw(root_handle) }.try_clone_to_owned()?; + job.terminate_and_wait(&root_handle, Duration::from_secs(10))?; + let child_status = unsafe { + WaitForSingleObject(process.as_raw_handle().cast(), /*dwMilliseconds*/ 0) + }; + assert_eq!(child_status, WAIT_OBJECT_0); tokio::time::timeout(Duration::from_secs(10), root.wait()).await??; Ok(()) } @@ -346,7 +367,9 @@ async fn rejected_job_assignment_resumes_existing_job_member() -> anyhow::Result .ok_or_else(|| anyhow::anyhow!("missing suspended process id"))?; assert!( - !rejected_job.assign_and_resume_process(process_id)?, + rejected_job + .assign_and_resume_process(process_id)? + .is_none(), "unrelated nested job unexpectedly accepted the process" ); let stdout = root From 5e3496e0ad6a85d7f57cbe3b6d576db3b317766c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 24 Oct 2026 23:12:22 +0000 Subject: [PATCH 038/287] Share eligible local stdio MCP connections across chats (#57211) ## What changed Extend `mcp_connection_sharing` to pool compatible local stdio connections that negotiate MCP `2026-07-28`. Keep legacy servers and servers that request input during startup private. Preserve each chat's tool catalog and route input requests to the calling chat. Release connections independently for each chat, shutting down the shared server after the final release, and retire pooled connections on forced refresh without interrupting other chats. ## Testing Add pool, runtime, app-server, and core scenario tests covering protocol gating, startup cancellation, refresh, final-release shutdown, and separate tool visibility and simultaneous input responses across chats sharing one process. GitOrigin-RevId: 896b107267a20ec1bffccdefca6c40fe2945a022 --- .../tests/suite/v2/mcp_connection_sharing.rs | 172 +++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/codex-mcp/src/binding.rs | 104 +-- codex-rs/codex-mcp/src/binding_clients.rs | 7 +- codex-rs/codex-mcp/src/binding_tests.rs | 2 + codex-rs/codex-mcp/src/client_tool_catalog.rs | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 85 ++- .../src/connection_manager/resources.rs | 13 +- .../codex-mcp/src/connection_manager_tests.rs | 27 +- codex-rs/codex-mcp/src/connection_pool.rs | 445 ++++++++++++ .../codex-mcp/src/connection_pool_tests.rs | 657 ++++++++++++++++++ codex-rs/codex-mcp/src/lib.rs | 2 + codex-rs/codex-mcp/src/rmcp_client.rs | 146 ++-- .../src/rmcp_client/managed_client.rs | 150 ++++ codex-rs/codex-mcp/src/runtime.rs | 54 +- .../src/runtime_shared_connection_tests.rs | 172 +++++ codex-rs/core/src/mcp.rs | 6 + codex-rs/core/src/session/mcp.rs | 3 +- codex-rs/core/src/session/mcp_runtime.rs | 10 +- codex-rs/core/tests/suite/scenarios.rs | 3 + .../core/tests/suite/scenarios_mcp_sharing.rs | 179 +++++ ...ls_and_model_requested_input_per_chat.snap | 68 ++ codex-rs/features/src/lib.rs | 2 +- .../rmcp-client/src/bin/test_stdio_server.rs | 30 + 24 files changed, 2197 insertions(+), 142 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs create mode 100644 codex-rs/codex-mcp/src/connection_pool.rs create mode 100644 codex-rs/codex-mcp/src/connection_pool_tests.rs create mode 100644 codex-rs/codex-mcp/src/rmcp_client/managed_client.rs create mode 100644 codex-rs/codex-mcp/src/runtime_shared_connection_tests.rs create mode 100644 codex-rs/core/tests/suite/scenarios_mcp_sharing.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mcp_sharing__shared_mcp_keeps_tools_and_model_requested_input_per_chat.snap diff --git a/codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs b/codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs new file mode 100644 index 000000000000..6558b34c5598 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mcp_connection_sharing.rs @@ -0,0 +1,172 @@ +//! Exercises protocol-gated stdio sharing through chat startup, tool dispatch, and shutdown. + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::McpServerToolCallResponse; +use codex_app_server_protocol::ThreadClosedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::ThreadUnsubscribeStatus; +use core_test_support::skip_if_remote; +use core_test_support::stdio_server_bin; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; + +#[tokio::test] +async fn stdio_sharing_follows_protocol_and_enablement_for_loaded_chats() -> Result<()> { + skip_if_remote!(Ok(()), "the local stdio executable is a host-local fixture"); + for (modern, modern_without_sharing) in [(false, false), (true, false), (true, true)] { + let responses = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let command = toml::Value::String(stdio_server_bin()?); + // Exercise both pool-driven protocol selection and modern connections without pooling. + let mut config = format!("[features]\nmcp_2026_07_28 = {modern_without_sharing}\n"); + for name in ["alpha", "beta"] { + config.push_str(&format!( + "[mcp_servers.{name}]\ncommand = {command}\nenabled_tools = [\"echo\"]\n\ + [mcp_servers.{name}.env]\nMCP_TEST_DYNAMIC_SERVER_METADATA = \"1\"\n" + )); + if name == "beta" && modern { + config.push_str("CODEX_MCP_PROTOCOL_VERSION = \"2026-07-28\"\n"); + } else if name == "beta" { + config.push_str("CODEX_MCP_PROTOCOL_VERSION = \"2025-06-18\"\n"); + } + } + MockResponsesConfig::new(&responses.uri()) + .with_root_config("thread_unload_delay_secs = 0") + .with_extra_config(&config) + .write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + set_sharing(&mut app, /*enabled*/ true).await?; + let first = app.start_thread(ThreadStartParams::default()).await?.thread; + let mut first_processes = call_servers(&mut app, &first.id).await?; + assert_ne!(first_processes["alpha"], first_processes["beta"]); + let second = app.start_thread(ThreadStartParams::default()).await?.thread; + let mut second_processes = call_servers(&mut app, &second.id).await?; + for name in ["alpha", "beta"] { + assert_eq!( + first_processes[name] == second_processes[name], + name == "alpha" || modern + ); + } + if modern_without_sharing { + for sharing in [false, true] { + set_sharing(&mut app, sharing).await?; + first_processes = call_servers(&mut app, &first.id).await?; + second_processes = call_servers(&mut app, &second.id).await?; + for name in ["alpha", "beta"] { + assert_eq!(first_processes[name] == second_processes[name], sharing); + } + } + } + unload_thread(&mut app, &first.id).await?; + assert_eq!(call_servers(&mut app, &second.id).await?, second_processes); + unload_thread(&mut app, &second.id).await?; + #[cfg(unix)] + for process in first_processes.values().chain(second_processes.values()) { + core_test_support::process::wait_for_process_exit( + process + .strip_prefix("rmcp-test-process-") + .expect("the fixture identifies its PID"), + ) + .await?; + } + #[cfg(not(unix))] + { + let third = app.start_thread(ThreadStartParams::default()).await?.thread; + let third_processes = call_servers(&mut app, &third.id).await?; + for name in ["alpha", "beta"] { + assert_ne!(third_processes[name], first_processes[name]); + } + } + } + Ok(()) +} + +async fn set_sharing(app: &mut TestAppServer, enabled: bool) -> Result<()> { + let enablement = BTreeMap::from([("mcp_connection_sharing".to_string(), enabled)]); + let response: ExperimentalFeatureEnablementSetResponse = app + .request( + |request_id| ClientRequest::ExperimentalFeatureEnablementSet { + request_id, + params: ExperimentalFeatureEnablementSetParams { + enablement: enablement.clone(), + }, + }, + ) + .await?; + assert_eq!(response.enablement, enablement); + Ok(()) +} + +async fn call_servers( + app: &mut TestAppServer, + thread_id: &str, +) -> Result> { + let mut processes = HashMap::new(); + for name in ["alpha", "beta"] { + let response: McpServerToolCallResponse = app + .request(|request_id| ClientRequest::McpServerToolCall { + request_id, + params: McpServerToolCallParams { + thread_id: thread_id.to_string(), + server: name.to_string(), + tool: "echo".to_string(), + arguments: Some(json!({ "message": "ping" })), + meta: None, + }, + }) + .await?; + let result = response.structured_content.expect("echo result"); + let process = result["echo"] + .as_str() + .expect("the fixture identifies its process"); + processes.insert(name, process.to_owned()); + } + Ok(processes) +} + +async fn unload_thread(app: &mut TestAppServer, thread_id: &str) -> Result<()> { + let response: ThreadUnsubscribeResponse = app + .request(|request_id| ClientRequest::ThreadUnsubscribe { + request_id, + params: ThreadUnsubscribeParams { + thread_id: thread_id.to_string(), + }, + }) + .await?; + assert_eq!( + response, + ThreadUnsubscribeResponse { + status: ThreadUnsubscribeStatus::Unsubscribed, + } + ); + let closed: ThreadClosedNotification = timeout( + Duration::from_secs(/*secs*/ 10), + app.read_notification("thread/closed"), + ) + .await??; + assert_eq!( + closed, + ThreadClosedNotification { + thread_id: thread_id.to_string(), + } + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 7532c62a6417..42c6ecb14ff6 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -87,6 +87,7 @@ mod luna_reserve; mod marketplace_add; mod marketplace_remove; mod marketplace_upgrade; +mod mcp_connection_sharing; mod mcp_event_stream; mod mcp_protocol_default; mod mcp_resource; diff --git a/codex-rs/codex-mcp/src/binding.rs b/codex-rs/codex-mcp/src/binding.rs index a5c17924dcd9..fb9c00f8b5e2 100644 --- a/codex-rs/codex-mcp/src/binding.rs +++ b/codex-rs/codex-mcp/src/binding.rs @@ -398,55 +398,63 @@ impl PreparedMcpCall { (server_timeout, requested_timeout) => server_timeout.or(requested_timeout), }; let tool_name = self.tool_info().tool.name.to_string(); - self.client - .tool_catalog - .run_with_snapshot(&self.catalog_snapshot, || async { - let (arguments, meta) = prepare().await?; - let timeout_deadline = - effective_timeout.map(|timeout| tokio::time::Instant::now() + timeout); - let add_trusted_access_context = self.connections.add_trusted_access_context( - self.tool_info(), - &self.server_metadata, - arguments.as_ref(), - meta, - ); - let meta = match effective_timeout.zip(timeout_deadline) { - Some((timeout, deadline)) => { - tokio::time::timeout_at(deadline, add_trusted_access_context) - .await - .map_err(|_| { - codex_rmcp_client::request_timeout_error("tools/call", timeout) - })? - } - None => add_trusted_access_context.await, - }; - let remaining_timeout = match effective_timeout.zip(timeout_deadline) { - Some((timeout, deadline)) => { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - return Err(codex_rmcp_client::request_timeout_error("tools/call", timeout)); + let operation = + self.client + .tool_catalog + .run_with_snapshot(&self.catalog_snapshot, || async { + let (arguments, meta) = prepare().await?; + let timeout_deadline = + effective_timeout.map(|timeout| tokio::time::Instant::now() + timeout); + let add_trusted_access_context = self.connections.add_trusted_access_context( + self.tool_info(), + &self.server_metadata, + arguments.as_ref(), + meta, + ); + let meta = match effective_timeout.zip(timeout_deadline) { + Some((timeout, deadline)) => { + tokio::time::timeout_at(deadline, add_trusted_access_context) + .await + .map_err(|_| { + codex_rmcp_client::request_timeout_error("tools/call", timeout) + })? } - Some(remaining) - } - None => None, - }; - self.client - .client - .call_tool( - tool_name.clone(), - arguments, - self.connections.network_policy_meta_for_call( - &self.client, - Some(self.permission_profile()), - self.server_environment_id(), - meta, - ), - remaining_timeout, - ) - .await - .with_context(|| format!("tool call failed for `{}/{tool_name}`", self.server_name)) - }) - .await + None => add_trusted_access_context.await, + }; + let remaining_timeout = match effective_timeout.zip(timeout_deadline) { + Some((timeout, deadline)) => { + let remaining = + deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(codex_rmcp_client::request_timeout_error( + "tools/call", + timeout, + )); + } + Some(remaining) + } + None => None, + }; + self.client + .call_tool( + tool_name.clone(), + arguments, + self.connections.network_policy_meta_for_call( + &self.client, + Some(self.permission_profile()), + self.server_environment_id(), + meta, + ), + remaining_timeout, + ) + .await + .with_context(|| { + format!("tool call failed for `{}/{tool_name}`", self.server_name) + }) + }); + self.client + .with_active_shared_lease(operation) + .await? .ok_or_else(|| anyhow::anyhow!( "tool call rejected because the catalog changed after `{}/{tool_name}` was prepared", self.server_name diff --git a/codex-rs/codex-mcp/src/binding_clients.rs b/codex-rs/codex-mcp/src/binding_clients.rs index b2b1c174b8d5..5ef9c4baf9c1 100644 --- a/codex-rs/codex-mcp/src/binding_clients.rs +++ b/codex-rs/codex-mcp/src/binding_clients.rs @@ -40,7 +40,6 @@ impl McpBindingClients { .client(server) .ok_or_else(|| anyhow!("MCP server '{server}' was not ready for this step"))?; managed - .client .list_resources(params, managed.tool_timeout) .await .with_context(|| format!("resources/list failed for `{server}`")) @@ -55,7 +54,6 @@ impl McpBindingClients { .client(server) .ok_or_else(|| anyhow!("MCP server '{server}' was not ready for this step"))?; managed - .client .list_resource_templates(params, managed.tool_timeout) .await .with_context(|| format!("resources/templates/list failed for `{server}`")) @@ -71,7 +69,6 @@ impl McpBindingClients { .ok_or_else(|| anyhow!("MCP server '{server}' was not ready for this step"))?; let uri = params.uri.clone(); managed - .client .read_resource(params, managed.tool_timeout) .await .with_context(|| format!("resources/read failed for `{server}` ({uri})")) @@ -88,7 +85,7 @@ impl McpBindingClients { .filter(|(server_name, _)| include_server(server_name)) { let server_name = server_name.clone(); - let client = Arc::clone(&managed.client); + let client = Arc::clone(managed); let timeout = managed.tool_timeout; join_set.spawn(async move { let resources = collect_paginated("resources/list", timeout, |params| { @@ -116,7 +113,7 @@ impl McpBindingClients { .filter(|(server_name, _)| include_server(server_name)) { let server_name = server_name.clone(); - let client = Arc::clone(&managed.client); + let client = Arc::clone(managed); let timeout = managed.tool_timeout; join_set.spawn(async move { let templates = collect_paginated("resources/templates/list", timeout, |params| { diff --git a/codex-rs/codex-mcp/src/binding_tests.rs b/codex-rs/codex-mcp/src/binding_tests.rs index d816befd1b65..60d33c8a1941 100644 --- a/codex-rs/codex-mcp/src/binding_tests.rs +++ b/codex-rs/codex-mcp/src/binding_tests.rs @@ -84,6 +84,8 @@ async fn test_step( /*updates*/ None, )); let managed_client = Arc::new(ManagedClient { + connection_sharing: Default::default(), + shared_elicitation: None, _auth_change_notifications: None, client: Arc::clone(&client), server_info: McpServerInfo { diff --git a/codex-rs/codex-mcp/src/client_tool_catalog.rs b/codex-rs/codex-mcp/src/client_tool_catalog.rs index 1bb85a8213b5..cec29a91906e 100644 --- a/codex-rs/codex-mcp/src/client_tool_catalog.rs +++ b/codex-rs/codex-mcp/src/client_tool_catalog.rs @@ -69,6 +69,7 @@ enum CatalogSource { }, } +#[derive(Clone)] pub(crate) struct ToolCatalogSnapshot { pub(crate) revision: u64, pub(crate) tools: Arc<[ToolInfo]>, diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 2ad43785d258..06c835e18034 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -41,6 +41,8 @@ use std::time::Duration; use crate::McpProtocolMode; use crate::binding::call_tool_result_from_rmcp; use crate::catalog::McpServerSource; +use crate::connection_pool::ConnectionKey; +use crate::connection_pool::ConnectionSharing; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestRouter; use crate::event_stream::EventStreamConnectionSettings; @@ -279,8 +281,8 @@ impl McpServerView { let timeout = self.refresh_timeout; let result = client .tool_catalog - .refresh_after_notifications(&client.client.tool_list_revision(), timeout, || { - crate::rmcp_client::list_tools_for_client_uncached( + .refresh_after_notifications(&client.client.tool_list_revision(), timeout, || async { + let relist = crate::rmcp_client::list_tools_for_client_uncached( server_name, /*is_codex_apps_mcp_server*/ false, "list_changed", @@ -288,7 +290,8 @@ impl McpServerView { Some(timeout), self.catalog_item_limit, client.server_instructions.as_deref(), - ) + ); + client.with_active_shared_lease(relist.boxed()).await? }) .await; if result.is_err() { @@ -675,6 +678,12 @@ impl McpConnectionSet { expected_protocol_mode }; let protocol_mode = expected_protocol_mode.unwrap_or(protocol_mode); + let connection_pool = runtime_context.connection_pool.as_ref().filter(|_| { + // Sharing a signed-in helper requires tracking account changes. + discover_local_stdio + && protocol_mode != McpProtocolMode::Legacy + && (auth.is_none() || auth_manager.is_some()) + }); if matches!(configured_config.auth, McpServerAuth::EmaAuth) && existing_ema.is_some() { if let Some(previous_view) = existing_ema .filter(|_| elicitation_previous.is_some()) @@ -743,6 +752,10 @@ impl McpConnectionSet { && (same_owner || !previous_view.connection.may_use_legacy_fallback()) { let connection = Arc::clone(&previous_view.connection); + // A failed pool acquisition can leave this chat on its private helper. + let retained_legacy = connection.retained_legacy().is_some(); + let can_reuse = retained_legacy + || connection.client.connection_slot.is_some() == connection_pool.is_some(); let reusable_pending_startup = connection.identity.as_ref() == Some(&connection_identity) && !connection.client.startup_complete.load(Ordering::Acquire) @@ -770,15 +783,15 @@ impl McpConnectionSet { .reusable_client(&connection_identity) .await .filter(|_| previous_view.catalog_item_limit == catalog_item_limit); - if reusable_pending_startup - || unchanged_auth_failure.is_some() - || ready.as_ref().is_some_and(|client| { - expected_protocol_mode.is_some_and(|expected| { - client.client.protocol_mode() == expected - || (previous_view.protocol_mode == expected - && connection.retained_legacy().is_some()) - }) - }) + if can_reuse + && (reusable_pending_startup + || unchanged_auth_failure.is_some() + || ready.as_ref().is_some_and(|client| { + expected_protocol_mode.is_some_and(|expected| { + client.client.protocol_mode() == expected + || (previous_view.protocol_mode == expected && retained_legacy) + }) + })) { let pending_client = reusable_pending_startup.then(|| connection.client.clone()); @@ -853,6 +866,7 @@ impl McpConnectionSet { && !connection.client.cancel_token.is_cancelled() && ready.is_some_and(|client| { client.client.protocol_mode() == McpProtocolMode::Legacy + && matches!(client.connection_sharing, ConnectionSharing::Private) }) { legacy_to_upgrade = Some(connection); @@ -861,6 +875,24 @@ impl McpConnectionSet { let cancel_token = startup_cancellation_token.child_token(); let tool_catalog_cache_context = tool_catalog_cache_context.take(); let has_runtime_auth = runtime_auth_provider.is_some(); + let connection_pool = connection_pool.map(|pool| { + ( + pool.clone(), + ConnectionKey { + server_name: server_name.clone(), + identity: connection_identity.clone(), + account: codex_apps_tools_cache_key.clone(), + auth_state: auth_manager.as_ref().map(|manager| { + ( + Arc::clone(manager), + *manager.auth_change_state_receiver().borrow(), + ) + }), + startup_timeout, + catalog_item_limit, + }, + ) + }); let mut async_managed_client = AsyncManagedClient::new( ema_auth.clone(), server_name.clone(), @@ -890,6 +922,7 @@ impl McpConnectionSet { .map(|manager| manager.auth_change_state_receiver()), protocol_mode, catalog_item_limit, + connection_pool, ); // A forced reconnect must also wait for the previously published helper to exit. if let Some(previous) = existing_ema.and_then(|set| set.servers.get(&server_name)) { @@ -1192,14 +1225,29 @@ impl McpConnectionSet { || view.connection.client_for_call().await.is_ok() } - /// Stop all MCP clients owned by this manager and terminate stdio server processes. + /// Release this manager's clients, terminating shared stdio servers after their final release. pub async fn shutdown(&self) { let connections = self .servers .values() .map(|view| Arc::clone(&view.connection)) .collect::>(); - // Keep cleanup alive if an interrupt cancels the refresh that requested it. + Self::shutdown_connections(connections).await; + } + + /// Releases pool leases retained by older bindings. + pub(crate) async fn shutdown_shared_connections(&self) { + let connections = self + .servers + .values() + .filter(|view| view.connection.client.connection_slot.is_some()) + .map(|view| Arc::clone(&view.connection)) + .collect(); + Self::shutdown_connections(connections).await; + } + + /// Keeps cleanup alive if an interrupt cancels the refresh that requested it. + async fn shutdown_connections(connections: Vec>) { let shutdown_task = tokio::spawn(async move { join_all(connections.iter().map(|connection| connection.shutdown())).await; }); @@ -1214,6 +1262,14 @@ impl McpConnectionSet { } } + pub(crate) fn retire_shared_connections(&self) { + for view in self.servers.values() { + if let Some(slot) = &view.connection.client.connection_slot { + slot.retire(); + } + } + } + pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { self.tool_plugin_context .plugin_id_for_mcp_server_name(server_name) @@ -1304,7 +1360,6 @@ impl McpConnectionSet { meta.remove(ENTITLEMENT_CONTEXT_KEY); } let result: rmcp::model::CallToolResult = client - .client .call_tool( tool.to_string(), arguments, diff --git a/codex-rs/codex-mcp/src/connection_manager/resources.rs b/codex-rs/codex-mcp/src/connection_manager/resources.rs index b42f09da8969..a0b10b1eff09 100644 --- a/codex-rs/codex-mcp/src/connection_manager/resources.rs +++ b/codex-rs/codex-mcp/src/connection_manager/resources.rs @@ -46,14 +46,13 @@ impl McpConnectionSet { .filter(|(server_name, _)| include_server(server_name)) { let server_name = server_name.clone(); - let Ok(managed_client) = view.connection.client().await else { + let Ok(client) = view.connection.client().await else { continue; }; let timeout = view.tool_timeout; - let client = managed_client.client; join_set.spawn(async move { let resources = collect_paginated("resources/list", timeout, |params| { - let client = Arc::clone(&client); + let client = client.clone(); async move { let response = client.list_resources(params, timeout).await?; Ok((response.resources, response.next_cursor)) @@ -93,14 +92,13 @@ impl McpConnectionSet { .filter(|(server_name, _)| include_server(server_name)) { let server_name = server_name.clone(); - let Ok(managed_client) = view.connection.client().await else { + let Ok(client) = view.connection.client().await else { continue; }; let timeout = view.tool_timeout; - let client = managed_client.client; join_set.spawn(async move { let templates = collect_paginated("resources/templates/list", timeout, |params| { - let client = Arc::clone(&client); + let client = client.clone(); async move { let response = client.list_resource_templates(params, timeout).await?; Ok((response.resource_templates, response.next_cursor)) @@ -137,7 +135,6 @@ impl McpConnectionSet { ) -> Result { let (managed, timeout) = self.client_by_name(server).await?; managed - .client .list_resources(params, timeout) .await .with_context(|| format!("resources/list failed for `{server}`")) @@ -150,7 +147,6 @@ impl McpConnectionSet { ) -> Result { let (managed, timeout) = self.client_by_name(server).await?; managed - .client .list_resource_templates(params, timeout) .await .with_context(|| format!("resources/templates/list failed for `{server}`")) @@ -164,7 +160,6 @@ impl McpConnectionSet { let (managed, timeout) = self.client_by_name(server).await?; let uri = params.uri.clone(); managed - .client .read_resource(params, timeout) .await .with_context(|| format!("resources/read failed for `{server}` ({uri})")) diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 08323befbdfb..1eaa51d8ed4e 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -187,7 +187,7 @@ impl McpConnectionSet { } } -fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { +pub(crate) fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { ToolInfo { server_name: server_name.to_string(), supports_parallel_tool_calls: false, @@ -485,6 +485,8 @@ async fn legacy_tool_catalog_rejects_repeated_pagination_cursor() -> anyhow::Res async fn create_test_managed_client(tools: Vec) -> ManagedClient { ManagedClient { + connection_sharing: Default::default(), + shared_elicitation: None, _auth_change_notifications: None, client: Arc::new( RmcpClient::new_in_process_client(Arc::new(TestInProcessTransportFactory)) @@ -597,6 +599,7 @@ pub(crate) async fn create_ready_async_managed_client(tools: Vec) -> A codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(true)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), } @@ -770,6 +773,7 @@ fn create_gated_async_managed_client( codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete, + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -808,6 +812,8 @@ pub(crate) async fn create_test_manager_with_ready_apps_client( .await?; let managed_client = ManagedClient { + connection_sharing: Default::default(), + shared_elicitation: None, _auth_change_notifications: None, client, server_info: create_test_server_info("Codex Apps"), @@ -844,6 +850,7 @@ pub(crate) async fn create_test_manager_with_ready_apps_client( codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(true)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -899,6 +906,7 @@ fn create_test_manager_with_failed_apps_startup( codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(true)), + connection_slot: None, startup_reconnect: Some(Arc::new(CodexAppsStartupReconnect::new(reconnect_factory))), cancel_token: CancellationToken::new(), }, @@ -2351,6 +2359,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_while_client_is_pending() { codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -2424,6 +2433,7 @@ async fn capture_binding_preserves_ready_client_catalog_and_shares_prepared_meta codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(true)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -2821,6 +2831,7 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -2919,6 +2930,7 @@ async fn capture_binding_exposes_cached_tools_before_startup() { codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, startup_complete, + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -3022,6 +3034,7 @@ async fn capture_binding_skips_pending_optional_servers_after_configured_shared_ codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -3277,6 +3290,7 @@ async fn capture_binding_shares_optional_startup_grace_across_connection_sets() codex_apps_tools_cache_context: None, tool_catalog_cache_context: Some(cache_context.clone()), startup_complete: Arc::new(AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -3967,6 +3981,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_cached_tools() { codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -4026,6 +4041,7 @@ async fn shutdown_cancels_pending_tool_listing() { codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token, }, @@ -4074,6 +4090,7 @@ async fn shutdown_continues_after_caller_is_aborted() { codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -4128,6 +4145,7 @@ async fn list_all_tools_does_not_block_when_shared_codex_apps_cache_is_empty() { codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -4184,6 +4202,7 @@ async fn list_all_tools_uses_shared_codex_apps_cache_when_client_startup_fails() codex_apps_tools_cache_context: Some(cache_context), tool_catalog_cache_context: None, startup_complete, + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -5357,7 +5376,7 @@ fn reusable_server_runtime_context() -> McpRuntimeContext { ) } -fn reusable_server_identity( +pub(crate) fn reusable_server_identity( server_name: &str, config: &McpServerConfig, runtime_context: &McpRuntimeContext, @@ -5815,6 +5834,8 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a ) .await?; let managed_client = ManagedClient { + connection_sharing: Default::default(), + shared_elicitation: None, _auth_change_notifications: None, client: Arc::clone(&client), server_info: create_test_server_info("Mutable tools"), @@ -5857,6 +5878,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(true)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, @@ -6693,6 +6715,7 @@ async fn reconciliation_replaces_closed_connections() -> anyhow::Result<()> { codex_apps_tools_cache_context: None, tool_catalog_cache_context: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(true)), + connection_slot: None, startup_reconnect: None, cancel_token: CancellationToken::new(), }, diff --git a/codex-rs/codex-mcp/src/connection_pool.rs b/codex-rs/codex-mcp/src/connection_pool.rs new file mode 100644 index 000000000000..a44aba1184eb --- /dev/null +++ b/codex-rs/codex-mcp/src/connection_pool.rs @@ -0,0 +1,445 @@ +//! Shares MCP 2026-07-28 connections while keeping each chat's ownership separate. +//! +//! The protocol requires request-scoped context and explicit handles for application state. +//! Request continuations use the calling chat's elicitation authority. Legacy connections and +//! servers that request input during startup remain private. + +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::OnceLock; +use std::sync::Weak; +use std::time::Duration; + +use codex_async_utils::OrCancelExt; +use codex_connectors::ConnectorRuntimeContextKey; +use codex_login::AuthChangeState; +use codex_login::AuthManager; +use codex_rmcp_client::ElicitationResponse; +use codex_rmcp_client::SendElicitation; +use futures::FutureExt; +use rmcp::model::ElicitationAction; +use rmcp::model::ProtocolVersion; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; + +use crate::client_tool_catalog::ClientToolCatalog; +use crate::client_tool_catalog::ToolCatalogSnapshot; +use crate::rmcp_client::ManagedClient; +use crate::rmcp_client::ManagedClientFuture; +use crate::rmcp_client::StartupOutcomeError; +use crate::rmcp_client::list_tools_for_client_uncached; +use crate::server::McpServerConnectionIdentity; +use crate::tool_catalog_cache::McpToolCatalogCacheContext; + +/// Process-owned registry for compatible local stdio servers using MCP 2026-07-28. +#[derive(Clone, Default)] +pub struct McpConnectionPool { + entries: Arc>>>, +} + +#[derive(Clone)] +pub(crate) struct ConnectionKey { + pub(crate) server_name: String, + pub(crate) identity: McpServerConnectionIdentity, + pub(crate) account: ConnectorRuntimeContextKey, + pub(crate) auth_state: Option<(Arc, AuthChangeState)>, + pub(crate) startup_timeout: Duration, + pub(crate) catalog_item_limit: usize, +} + +impl PartialEq for ConnectionKey { + fn eq(&self, other: &Self) -> bool { + self.server_name == other.server_name + && self.identity == other.identity + && self.account == other.account + && self.startup_timeout == other.startup_timeout + && self.catalog_item_limit == other.catalog_item_limit + && match (&self.auth_state, &other.auth_state) { + (Some((left, left_state)), Some((right, right_state))) => { + Arc::ptr_eq(left, right) + && left_state.owner_generation == right_state.owner_generation + && left_state.source_generation == right_state.source_generation + && left_state.login_generation == right_state.login_generation + } + (None, None) => true, + (Some(_), None) | (None, Some(_)) => false, + } + } +} + +struct Entry { + key: ConnectionKey, + client: ManagedClientFuture, + capabilities: Arc>>, + // Share immutable definitions; every chat owns its catalog revisions and refresh locks. + catalog: Mutex>, + cancellation: CancellationToken, + /// Wakes joiners without cancelling the creator's startup. + sharing_declined: CancellationToken, + owners: Weak, + pool: Weak>>>, +} + +/// One strong reference per active chat lease, independent of retained client bindings. +/// Every strong reference must be consumed by `ConnectionLease::release`. +struct LeaseOwners; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AcquisitionRole { + Creator, + Joiner, +} + +impl Entry { + fn remove_from_pool(self: &Arc) { + if let Some(pool) = self.pool.upgrade() { + pool.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|entry| !entry.ptr_eq(&Arc::downgrade(self))); + } + } + + async fn tool_catalog_for_chat( + &self, + client: &ManagedClient, + role: AcquisitionRole, + cache_context: Option<&McpToolCatalogCacheContext>, + ) -> Result, StartupOutcomeError> { + // Listing must not update another chat's catalog or wait for its calls. + let listed = if role == AcquisitionRole::Creator { + client.tool_catalog.read(|snapshot| snapshot).await + } else { + let fetch_ticket = cache_context.map(|context| (context, context.begin_fetch())); + let (tools, meta) = list_tools_for_client_uncached( + &self.key.server_name, + self.key.server_name == crate::CODEX_APPS_MCP_SERVER_NAME, + "shared_connection", + &client.client, + Some(self.key.startup_timeout), + self.key.catalog_item_limit, + client.server_instructions.as_deref(), + ) + .await?; + if let Some((context, mut ticket)) = fetch_ticket { + if context.track_tool_list_changes { + ticket.tool_list_revision = Some(client.client.tool_list_revision()); + } + context.publish_if_newest(ticket, &tools, meta.clone()); + } + ToolCatalogSnapshot { + revision: 0, + tools: tools.into(), + meta, + } + }; + let mut catalog = self + .catalog + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let catalog = catalog.get_or_insert_with(|| listed.clone()); + if catalog.tools != listed.tools || catalog.meta != listed.meta { + *catalog = listed; + } + Ok(Arc::new(ClientToolCatalog::new( + Arc::clone(&catalog.tools), + catalog.meta.clone(), + /*updates*/ None, + ))) + } +} + +/// A chat releases its lease explicitly on shutdown, even while bindings retain its client. +pub(crate) struct ConnectionLease { + entry: Arc, + owner: Mutex>>, + cancellation: CancellationToken, + slot: Arc, +} + +/// Tracks an acquisition without retaining its lease, including before startup completes. +#[derive(Default)] +pub(crate) struct ConnectionSlot { + state: Mutex, + shutdown_tasks: OnceLock, +} + +#[derive(Default)] +enum SlotState { + #[default] + Available, + Acquired(Weak), + Retired, +} + +impl ConnectionSlot { + pub(crate) fn retire(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let SlotState::Acquired(entry) = std::mem::replace(&mut *state, SlotState::Retired) + && let Some(entry) = entry.upgrade() + { + entry.remove_from_pool(); + } + } + + pub(crate) async fn wait_for_shutdown(&self) { + if let Some(tasks) = self.shutdown_tasks.get() { + tasks.close(); + tasks.wait().await; + } + } +} + +#[derive(Clone, Default)] +pub(crate) enum ConnectionSharing { + #[default] + Private, + Negotiated, + /// The server declined sharing, but its creator still owns the startup entry. + Owned(Arc), + Shared(Arc), +} + +impl McpConnectionPool { + pub(crate) async fn connect( + &self, + slot: Arc, + key: ConnectionKey, + capabilities: Arc>>, + tool_catalog_cache_context: Option, + cancellation: CancellationToken, + start: impl Fn(CancellationToken, Option) -> ManagedClientFuture + Sync, + ) -> Result { + let (lease, role) = self + .acquire(key, &slot, Arc::clone(&capabilities), &cancellation, &start) + .or_cancel(&cancellation) + .await + .unwrap_or(Err(StartupOutcomeError::Cancelled))?; + let outcome = tokio::select! { + outcome = lease.entry.client.clone() => Some(outcome), + () = lease.entry.sharing_declined.cancelled(), if role == AcquisitionRole::Joiner => None, + () = cancellation.cancelled() => return Err(StartupOutcomeError::Cancelled), + }; + match outcome { + Some(Ok(mut client)) + if role == AcquisitionRole::Creator + || matches!(client.connection_sharing, ConnectionSharing::Negotiated) => + { + let shared_capabilities = lease + .entry + .capabilities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + *capabilities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = shared_capabilities; + client.connection_sharing = + if matches!(client.connection_sharing, ConnectionSharing::Negotiated) { + client.client.enable_request_cancellation(); + client.tool_catalog = lease + .entry + .tool_catalog_for_chat( + &client, + role, + tool_catalog_cache_context.as_ref(), + ) + .or_cancel(&cancellation) + .await + .unwrap_or(Err(StartupOutcomeError::Cancelled))?; + ConnectionSharing::Shared(lease) + } else { + lease.entry.remove_from_pool(); + ConnectionSharing::Owned(lease) + }; + Ok(client) + } + Some(Err(error)) if role == AcquisitionRole::Creator => Err(error), + _ => { + // Older or failed servers leave other chats to initialize privately + // with their own elicitation authority. + drop(lease); + start(cancellation, /*sharing_declined*/ None).await + } + } + } + + async fn acquire( + &self, + key: ConnectionKey, + slot: &Arc, + capabilities: Arc>>, + cancellation: &CancellationToken, + start: impl Fn(CancellationToken, Option) -> ManagedClientFuture, + ) -> Result<(Arc, AcquisitionRole), StartupOutcomeError> { + loop { + let (lease, role) = { + let mut state = slot + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!(&*state, SlotState::Retired) { + return Err(StartupOutcomeError::Cancelled); + } + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + entries.retain(|entry| entry.strong_count() > 0); + let lease_cancellation = cancellation.child_token(); + let existing = entries + .iter() + .filter_map(Weak::upgrade) + .filter(|entry| { + entry.key == key + && !entry.sharing_declined.is_cancelled() + && entry.client.peek().is_none_or(|result| { + result.as_ref().is_ok_and(|client| { + matches!( + client.connection_sharing, + ConnectionSharing::Negotiated + ) + }) + }) + }) + .find_map(|entry| entry.owners.upgrade().map(|owner| (entry, owner))); + let (entry, owner, role) = if let Some((entry, owner)) = existing { + (entry, owner, AcquisitionRole::Joiner) + } else { + let startup_cancellation = CancellationToken::new(); + let sharing_declined = CancellationToken::new(); + let client = + start(startup_cancellation.clone(), Some(sharing_declined.clone())); + // Startup belongs to the connection, including if its first chat is cancelled. + tokio::spawn(client.clone()); + let owner = Arc::new(LeaseOwners); + let entry = Arc::new(Entry { + key: key.clone(), + client, + capabilities: Arc::clone(&capabilities), + catalog: Mutex::new(None), + cancellation: startup_cancellation, + sharing_declined, + owners: Arc::downgrade(&owner), + pool: Arc::downgrade(&self.entries), + }); + entries.push(Arc::downgrade(&entry)); + (entry, owner, AcquisitionRole::Creator) + }; + let lease = Arc::new(ConnectionLease { + entry, + owner: Mutex::new(Some(owner)), + cancellation: lease_cancellation, + slot: Arc::clone(slot), + }); + *state = SlotState::Acquired(Arc::downgrade(&lease.entry)); + (lease, role) + }; + if let Some(Ok(client)) = lease.entry.client.peek() + && client.client.is_closed().await + { + lease.entry.remove_from_pool(); + lease.release(); + continue; + } + return Ok((lease, role)); + } + } +} + +impl ConnectionLease { + pub(crate) fn cancellation(&self) -> &CancellationToken { + &self.cancellation + } + + /// Returns the slot tracking shutdown when this chat releases the last lease. + pub(crate) fn release(&self) -> Option<&ConnectionSlot> { + self.cancellation.cancel(); + let mut owner = self + .owner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Arc::into_inner(owner.take()?)?; + self.entry.cancellation.cancel(); + self.entry.remove_from_pool(); + let client = self.entry.client.clone(); + let runtime = tokio::runtime::Handle::try_current().ok()?; + // The chat must be able to await its final release even when cancellation drops the lease. + let tasks = self.slot.shutdown_tasks.get_or_init(TaskTracker::new); + drop(tasks.spawn_on( + async move { + match client.await { + Ok(client) => client.client.shutdown().await, + Err(error) => error.wait_for_shutdown().await, + } + }, + &runtime, + )); + Some(&self.slot) + } +} + +impl Drop for ConnectionLease { + fn drop(&mut self) { + let _ = self.release(); + } +} + +/// Releases waiting chats when startup becomes private and detaches callbacks after sharing. +pub(crate) struct SharingNegotiation { + callback: Arc>>, + sharing_declined: CancellationToken, +} + +impl SharingNegotiation { + pub(crate) fn new( + sender: SendElicitation, + sharing_declined: CancellationToken, + ) -> (Self, SendElicitation) { + let callback = Arc::new(Mutex::new(Some(sender))); + let negotiation = Self { + callback: Arc::clone(&callback), + sharing_declined: sharing_declined.clone(), + }; + let sender: SendElicitation = Box::new(move |id, request| { + let callback = callback + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(callback) = callback.as_ref() { + // Client input binds this startup to its original chat. + sharing_declined.cancel(); + callback(id, request) + } else { + futures::future::ready(Ok(ElicitationResponse { + action: ElicitationAction::Cancel, + content: None, + meta: None, + })) + .boxed() + } + }); + (negotiation, sender) + } + + pub(crate) fn finish(self, protocol_version: &ProtocolVersion) -> ConnectionSharing { + let mut callback = self + .callback + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if *protocol_version == ProtocolVersion::V_2026_07_28 + && !self.sharing_declined.is_cancelled() + { + callback.take(); + ConnectionSharing::Negotiated + } else { + self.sharing_declined.cancel(); + ConnectionSharing::Private + } + } +} + +#[cfg(test)] +#[path = "connection_pool_tests.rs"] +pub(crate) mod tests; diff --git a/codex-rs/codex-mcp/src/connection_pool_tests.rs b/codex-rs/codex-mcp/src/connection_pool_tests.rs new file mode 100644 index 000000000000..9f330aa35cf0 --- /dev/null +++ b/codex-rs/codex-mcp/src/connection_pool_tests.rs @@ -0,0 +1,657 @@ +//! Tests for shared MCP connection ownership and protocol negotiation. + +use super::*; +use crate::binding_clients::McpBindingClients; +use crate::connection_manager::tests::create_ready_async_managed_client; +use crate::connection_manager::tests::create_test_tool; +use crate::connection_manager::tests::reusable_server_identity; +use crate::runtime::McpRuntimeContext; +use codex_config::McpServerConfig; +use codex_exec_server::EnvironmentManager; +use codex_login::CodexAuth; +use codex_rmcp_client::Elicitation; +use codex_rmcp_client::InProcessTransportFactory; +use codex_rmcp_client::RmcpClient; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; +use rmcp::ServerHandler; +use rmcp::ServiceExt; +use rmcp::model::ClientCapabilities; +use rmcp::model::Implementation; +use rmcp::model::InitializeRequestParams; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; +use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParams; +use rmcp::model::ProtocolVersion; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerConfig; +use rmcp::model::Tool; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use serde_json::json; +use std::collections::HashMap; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tokio::sync::Notify; +use tokio::sync::Semaphore; + +#[derive(Clone)] +pub(crate) struct TestServer { + tools: Arc>>, + block_listing: Arc, + started_listings: Arc, + cancelled_listings: Arc, +} + +impl Default for TestServer { + fn default() -> Self { + Self { + tools: Arc::new(Mutex::new(vec![ + create_test_tool("test_server", "test").tool, + ])), + block_listing: Arc::default(), + started_listings: Arc::new(Semaphore::new(/*permits*/ 0)), + cancelled_listings: Arc::new(Semaphore::new(/*permits*/ 0)), + } + } +} + +impl ServerHandler for TestServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + } + + async fn call_tool( + &self, + _request: rmcp::model::CallToolRequestParams, + _context: RequestContext, + ) -> Result { + Ok(rmcp::model::CallToolResult::success(Vec::new()).into()) + } + + async fn list_tools( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + if self.block_listing.load(Ordering::Acquire) { + context.ct.cancelled().await; + self.cancelled_listings.add_permits(1); + return Err(rmcp::ErrorData::internal_error( + "listing cancelled", + /*data*/ None, + )); + } + Ok(ListToolsResult::with_all_items( + self.tools.lock().unwrap().clone(), + )) + } + + async fn list_resources( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + if self.block_listing.load(Ordering::Acquire) { + self.started_listings.add_permits(1); + context.ct.cancelled().await; + self.cancelled_listings.add_permits(1); + } + Ok(ListResourcesResult::with_all_items(Vec::new())) + } + + async fn list_resource_templates( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + if self.block_listing.load(Ordering::Acquire) { + self.started_listings.add_permits(1); + context.ct.cancelled().await; + self.cancelled_listings.add_permits(1); + } + Ok(ListResourceTemplatesResult::with_all_items(Vec::new())) + } +} + +impl InProcessTransportFactory for TestServer { + fn open(&self) -> BoxFuture<'static, std::io::Result> { + let server = self.clone(); + async move { + let (client, transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + server + .serve(transport) + .await + .unwrap() + .waiting() + .await + .unwrap(); + }); + Ok(client) + } + .boxed() + } +} + +pub(crate) fn key(context: &McpRuntimeContext, config: &McpServerConfig) -> ConnectionKey { + ConnectionKey { + server_name: "test_server".to_string(), + identity: reusable_server_identity("test_server", config, context), + account: ConnectorRuntimeContextKey::personal( + /*account_id*/ None, /*chatgpt_user_id*/ None, + ), + auth_state: None, + startup_timeout: Duration::from_secs(30), + catalog_item_limit: 100, + } +} + +pub(crate) fn context() -> McpRuntimeContext { + McpRuntimeContext::new( + Arc::new(EnvironmentManager::default_for_tests()), + std::env::temp_dir(), + ) +} + +pub(crate) fn config() -> McpServerConfig { + serde_json::from_value(json!({"command": "test-server", "args": ["server.mjs"]})).unwrap() +} + +pub(crate) fn startup( + sharing: ConnectionSharing, + starts: Arc, + first_started: Arc, + release_first: Arc, + server: TestServer, +) -> impl Fn(CancellationToken, Option) -> ManagedClientFuture { + move |cancellation, _sharing_declined| { + let sharing = sharing.clone(); + let starts = Arc::clone(&starts); + let first_started = Arc::clone(&first_started); + let release_first = Arc::clone(&release_first); + let server = server.clone(); + async move { + async { + if starts.fetch_add(1, Ordering::SeqCst) == 0 { + first_started.notify_one(); + release_first.notified().await; + } + let mut client = create_ready_async_managed_client(Vec::new()) + .await + .client() + .await?; + client.client = Arc::new( + RmcpClient::new_in_process_client(Arc::new(server)) + .await + .expect("create test MCP client"), + ); + client + .client + .initialize( + InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("test", "1"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18), + Some(Duration::from_secs(30)), + Box::new(|_, _| { + futures::future::ready(Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + })) + .boxed() + }), + ) + .await?; + let (tools, meta) = list_tools_for_client_uncached( + "test_server", + /*is_codex_apps_mcp_server*/ false, + "test", + &client.client, + /*timeout*/ None, + /*catalog_item_limit*/ 100, + /*server_instructions*/ None, + ) + .await?; + client.tool_catalog = + Arc::new(ClientToolCatalog::new(tools, meta, /*updates*/ None)); + client.connection_sharing = sharing; + Ok(client) + } + .or_cancel(&cancellation) + .await + .unwrap_or(Err(StartupOutcomeError::Cancelled)) + } + .boxed() + .shared() + } +} + +#[tokio::test] +async fn shared_startup_survives_first_chat_cancellation_and_rejects_released_bindings() { + let pool = McpConnectionPool::default(); + let key = key(&context(), &config()); + let server = TestServer::default(); + let starts = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let launch = startup( + ConnectionSharing::Negotiated, + Arc::clone(&starts), + Arc::clone(&started), + Arc::clone(&release), + server.clone(), + ); + let first_cancel = CancellationToken::new(); + let first = pool.connect( + Arc::default(), + key.clone(), + Arc::default(), + /*tool_catalog_cache_context*/ None, + first_cancel.clone(), + &launch, + ); + let second = pool.connect( + Arc::default(), + key.clone(), + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &launch, + ); + tokio::pin!(first, second); + assert!(futures::poll!(&mut first).is_pending()); + assert!(futures::poll!(&mut second).is_pending()); + started.notified().await; + first_cancel.cancel(); + assert!(matches!(first.await, Err(StartupOutcomeError::Cancelled))); + release.notify_one(); + let client = second.await.unwrap(); + assert_eq!(starts.load(Ordering::SeqCst), 1); + assert!(!client.client.is_closed().await); + + let other = pool + .connect( + Arc::default(), + key, + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &launch, + ) + .await + .unwrap(); + let mut config = crate::mcp::tests::test_mcp_config(std::env::temp_dir()); + config + .server_permission_profiles + .insert("test_server".to_string(), config.permission_profile.clone()); + let prepared = crate::binding::PreparedMcpCall::new( + Arc::new(crate::connection_manager::McpConnectionSet::empty( + /*prefix_mcp_tool_names*/ true, + )), + Arc::new(client.clone()), + Arc::new(config), + client.tool_catalog.read(Arc::new).await, + create_test_tool("test_server", "test"), + crate::server::McpServerMetadata { + environment_id: "local".to_string(), + pollutes_memory: false, + origin: None, + supports_parallel_tool_calls: true, + default_tools_approval_mode: None, + tool_approval_modes: Default::default(), + }, + /*plugin_id*/ None, + /*selected_plugin_server*/ false, + ) + .unwrap(); + let bindings = McpBindingClients::new(HashMap::from([( + "test_server".to_string(), + Arc::new(client.clone()), + )])); + server.block_listing.store(true, Ordering::Release); + let waiting = async { + tokio::join!( + bindings.list_resources("test_server", /*params*/ None), + bindings.list_resource_templates("test_server", /*params*/ None), + ) + }; + tokio::pin!(waiting); + tokio::select! { + _ = &mut waiting => panic!("resource listings must wait for cancellation"), + permits = server.started_listings.acquire_many(2) => permits.unwrap().forget(), + } + let mut owned = create_ready_async_managed_client(Vec::new()).await; + owned.client = futures::future::ready(Ok(client.clone())).boxed().shared(); + owned.shutdown().await; + let (resources, templates) = waiting.await; + assert!(resources.is_err()); + assert!(templates.is_err()); + tokio::time::timeout( + Duration::from_secs(1), + server.cancelled_listings.acquire_many(2), + ) + .await + .expect("released resource listings must be cancelled") + .unwrap() + .forget(); + let prepared_error = prepared + .call_with_preparation(/*requested_timeout*/ None, || async { + panic!("released bindings must not perform irreversible preparation") + }) + .await + .unwrap_err(); + assert!( + prepared_error + .to_string() + .contains("lease has been released") + ); + other + .call_tool( + "test".to_string(), + /*arguments*/ None, + |_| Ok(None), + /*timeout*/ None, + ) + .await + .unwrap(); + owned.client = futures::future::ready(Ok(other)).boxed().shared(); + owned.shutdown().await; +} + +#[tokio::test] +async fn callback_during_startup_releases_waiting_chats_without_sharing_authority() { + let pool = McpConnectionPool::default(); + let key = key(&context(), &config()); + let starts = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let second_callback = Arc::new(Notify::new()); + let release_second = Arc::new(Notify::new()); + let callbacks = Arc::new(Mutex::new(Vec::new())); + let launch = |name| { + let launch = startup( + ConnectionSharing::Private, + Arc::clone(&starts), + Arc::clone(&started), + Arc::clone(&release), + TestServer::default(), + ); + let callbacks = Arc::clone(&callbacks); + let second_callback = Arc::clone(&second_callback); + let release_second = Arc::clone(&release_second); + move |cancellation, sharing_declined: Option| { + let startup = launch(cancellation, sharing_declined.clone()); + let callbacks = Arc::clone(&callbacks); + let second_callback = Arc::clone(&second_callback); + let release_second = Arc::clone(&release_second); + async move { + let mut client = startup.await?; + let (negotiation, callback) = SharingNegotiation::new( + Box::new(move |_, _| { + callbacks.lock().unwrap().push(name); + futures::future::ready(Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + })) + .boxed() + }), + sharing_declined.unwrap_or_default(), + ); + callback( + rmcp::model::RequestId::Number(0), + Elicitation::OpenAiForm { + meta: None, + message: "startup prompt".to_string(), + requested_schema: json!({}), + }, + ) + .await + .unwrap(); + if name == "first" { + second_callback.notified().await; + } else { + second_callback.notify_one(); + release_second.notified().await; + } + client.connection_sharing = negotiation.finish(&ProtocolVersion::V_2026_07_28); + Ok(client) + } + .boxed() + .shared() + } + }; + let first = pool.connect( + Arc::default(), + key.clone(), + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + launch("first"), + ); + let second = pool.connect( + Arc::default(), + key, + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + launch("second"), + ); + tokio::pin!(first, second); + assert!(futures::poll!(&mut first).is_pending()); + assert!(futures::poll!(&mut second).is_pending()); + started.notified().await; + release.notify_one(); + let first = tokio::time::timeout(Duration::from_secs(10), async { + tokio::select! { + first = &mut first => first, + _ = &mut second => panic!("the joining chat should still be starting"), + } + }) + .await + .expect("the joining chat must start before the first finishes") + .unwrap(); + first + .call_tool( + "test".to_string(), + /*arguments*/ None, + |_| Ok(None), + /*timeout*/ None, + ) + .await + .unwrap(); + let mut owner = create_ready_async_managed_client(Vec::new()).await; + owner.client = futures::future::ready(Ok(first.clone())).boxed().shared(); + tokio::time::timeout(Duration::from_secs(10), owner.shutdown()) + .await + .expect("private owner shutdown must not wait for the joining chat"); + assert!(first.client.is_closed().await); + + release_second.notify_one(); + let second = second.await.unwrap(); + assert_eq!(starts.load(Ordering::SeqCst), 2); + assert_eq!(*callbacks.lock().unwrap(), ["first", "second"]); + second + .call_tool( + "test".to_string(), + /*arguments*/ None, + |_| Ok(None), + /*timeout*/ None, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn connection_key_changes_with_the_owner_but_survives_credential_refresh() { + let manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let mut original = key(&context(), &config()); + original.auth_state = Some((manager, AuthChangeState::default())); + let mut refreshed = original.clone(); + refreshed.auth_state.as_mut().unwrap().1.generation += 1; + assert!(original == refreshed); + + refreshed.auth_state.as_mut().unwrap().1.owner_generation += 1; + assert!(original != refreshed); +} + +#[tokio::test(start_paused = true)] +async fn new_chat_relists_tools_without_invalidating_or_waiting_for_existing_calls() { + let pool = McpConnectionPool::default(); + let key = key(&context(), &config()); + let server = TestServer::default(); + let starts = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(Notify::new()); + release.notify_one(); + let launch = startup( + ConnectionSharing::Negotiated, + Arc::clone(&starts), + Arc::default(), + release, + server.clone(), + ); + let first = pool + .connect( + Arc::default(), + key.clone(), + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &launch, + ) + .await + .unwrap(); + let before = first.tool_catalog.read(|snapshot| snapshot).await; + let release_call = Notify::new(); + let call = first + .tool_catalog + .run_with_snapshot(&before, || release_call.notified()); + tokio::pin!(call); + assert!(futures::poll!(&mut call).is_pending()); + for tool_name in ["test", "new"] { + *server.tools.lock().unwrap() = vec![create_test_tool("test_server", tool_name).tool]; + let second = tokio::time::timeout( + Duration::from_secs(1), + pool.connect( + Arc::default(), + key.clone(), + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &launch, + ), + ) + .await + .expect("new chats must not wait for existing calls") + .unwrap(); + assert_eq!(starts.load(Ordering::SeqCst), 1); + assert_eq!( + second + .tool_catalog + .read(|snapshot| snapshot.tools[0].tool.name.to_string()) + .await, + tool_name + ); + tokio::time::timeout( + Duration::from_secs(1), + second.tool_catalog.refresh( + || async { Ok((vec![create_test_tool("test_server", "refreshed")], None, ())) }, + |_, _, ()| {}, + ), + ) + .await + .expect("one chat's refresh must not wait for another chat's calls") + .unwrap(); + assert_eq!( + first + .tool_catalog + .read(|snapshot| snapshot.tools[0].tool.name.to_string()) + .await, + "test" + ); + } + release_call.notify_one(); + assert_eq!(call.await, Some(())); + + server.block_listing.store(true, Ordering::Release); + let failed = pool + .connect( + Arc::default(), + key, + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &launch, + ) + .await; + assert!(failed.is_err()); + let _cancelled = + tokio::time::timeout(Duration::from_secs(1), server.cancelled_listings.acquire()) + .await + .expect("timed-out listings must be cancelled") + .unwrap(); + first + .call_tool( + "test".to_string(), + /*arguments*/ None, + |_| Ok(None), + /*timeout*/ None, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn negotiation_requires_the_stateless_protocol_and_detaches_startup_callbacks() { + for (protocol_version, expected_sharing, expected_action) in [ + ( + ProtocolVersion::V_2025_06_18, + false, + ElicitationAction::Decline, + ), + ( + ProtocolVersion::V_2026_07_28, + true, + ElicitationAction::Cancel, + ), + ] { + let (negotiation, callback) = SharingNegotiation::new( + Box::new(|_, _| { + futures::future::ready(Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + })) + .boxed() + }), + CancellationToken::new(), + ); + let sharing = negotiation.finish(&protocol_version); + assert_eq!( + matches!(sharing, ConnectionSharing::Negotiated), + expected_sharing + ); + let response = callback( + rmcp::model::RequestId::Number(0), + Elicitation::OpenAiForm { + meta: None, + message: "unexpected".to_string(), + requested_schema: json!({}), + }, + ) + .await + .unwrap(); + assert_eq!(response.action, expected_action); + } +} diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 71db3cb5a729..a3e254917186 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -6,6 +6,7 @@ pub use codex_rmcp_client::McpProtocolMode; pub use connection_manager::NetworkPolicyMetadata; pub use connection_manager::NetworkPolicyMetadataProvider; pub use connection_manager::tool_is_model_visible; +pub use connection_pool::McpConnectionPool; pub use elicitation::ElicitationLifecycle; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; @@ -118,6 +119,7 @@ mod client_capabilities; mod client_tool_catalog; pub(crate) mod codex_apps; pub(crate) mod connection_manager; +mod connection_pool; pub(crate) mod elicitation; mod ema_account; mod ema_auth; diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 8e66a9ecfc23..018da8ae70c5 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -9,6 +9,8 @@ //! Higher-level aggregation and resource/tool APIs live in //! [`crate::connection_manager`]. +#[path = "rmcp_client/managed_client.rs"] +mod managed_client; #[path = "rmcp_client/status.rs"] mod status; @@ -16,6 +18,8 @@ mod status; #[path = "rmcp_client/provenance_tests.rs"] mod provenance_tests; +pub(crate) use managed_client::ManagedClient; + use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::HashMap; @@ -33,6 +37,10 @@ use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; use crate::codex_apps::normalize_codex_apps_tool_title; use crate::codex_apps::prepare_openai_file_params_for_model; +use crate::connection_pool::ConnectionKey; +use crate::connection_pool::ConnectionSharing; +use crate::connection_pool::ConnectionSlot; +use crate::connection_pool::SharingNegotiation; use crate::elicitation::ElicitationRequestManager; use crate::executor_environment_http_client::ExecutorEnvironmentHttpClient; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -94,7 +102,6 @@ use rmcp::model::Tool as RmcpTool; use tokio::sync::watch; use tokio::time::Instant as TokioInstant; use tokio_util::sync::CancellationToken; -use tokio_util::task::AbortOnDropHandle; use tracing::Instrument; use tracing::instrument; use tracing::warn; @@ -125,45 +132,6 @@ const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[ "connectorDescription", ]; -#[derive(Clone)] -pub(crate) struct ManagedClient { - pub(crate) _auth_change_notifications: Option>>, - pub(crate) client: Arc, - pub(crate) server_info: McpServerInfo, - pub(crate) tool_catalog: Arc, - pub(crate) tool_timeout: Option, - pub(crate) server_instructions: Option, - pub(crate) server_supports_sandbox_state_meta_capability: bool, - pub(crate) client_supports_network_policy_meta_capability: bool, - pub(crate) codex_apps_tools_cache_context: Option>, -} - -impl ManagedClient { - pub(crate) async fn listed_catalog(&self) -> (Vec, Option>) { - let total_start = Instant::now(); - self.tool_catalog - .read(|catalog| { - // Discovery may use the shared cache until this client is refreshed. - // Executable bindings always capture this client's own catalog. - if catalog.revision == 0 - && let Some(cache_context) = &self.codex_apps_tools_cache_context - { - let tools = cache_context.current_catalog_snapshot(); - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - total_start.elapsed(), - &[("cache", if tools.is_some() { "hit" } else { "miss" })], - ); - if let Some(tools) = tools { - return (tools.tools().to_vec(), tools.shared_tool_list_meta()); - } - } - (catalog.tools.to_vec(), catalog.meta) - }) - .await - } -} - pub(crate) type ManagedClientFuture = Shared>>; type StartupShutdown = Shared>>; @@ -328,7 +296,7 @@ struct ManagedClientStartup { } impl ManagedClientStartup { - fn start(&self) -> ManagedClientFuture { + fn start(&self, sharing_declined: Option) -> ManagedClientFuture { // A new attempt must not expose capabilities from an earlier connection. *self .server_capabilities @@ -408,6 +376,7 @@ impl ManagedClientStartup { server_name.clone(), client, StartServerTaskParams { + sharing_declined, is_codex_apps_mcp_server, startup_timeout: Some(startup_timeout), tx_event, @@ -491,6 +460,7 @@ pub(crate) struct AsyncManagedClient { pub(crate) tool_catalog_cache_context: Option, pub(crate) startup_complete: Arc, pub(crate) startup_reconnect: Option>, + pub(crate) connection_slot: Option>, pub(crate) cancel_token: CancellationToken, } @@ -520,6 +490,7 @@ impl AsyncManagedClient { auth_changes: Option>, protocol_mode: McpProtocolMode, catalog_item_limit: usize, + connection_pool: Option<(crate::McpConnectionPool, ConnectionKey)>, ) -> Self { let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; let reconnect_server_name = server_name.clone(); @@ -569,19 +540,64 @@ impl AsyncManagedClient { startup_complete: Arc::clone(&startup_complete), server_capabilities: Arc::clone(&server_capabilities), }); - // Startup can publish a legacy fallback. Readers wait for the negotiated catalog. + // A discovery attempt may publish a legacy catalog. It cannot read that catalog + // before negotiation or publish a modern joiner's tool list into it. let tool_catalog_cache_context = tool_catalog_cache_context .filter(|_| protocol_mode != McpProtocolMode::DiscoverLocalStdio); - let client = startup.start(); + let (client, connection_slot) = if let Some((pool, key)) = connection_pool { + let slot = Arc::new(ConnectionSlot::default()); + let connection_slot = Arc::clone(&slot); + let startup = Arc::clone(&startup); + let cancel_token = cancel_token.clone(); + let startup_complete = Arc::clone(&startup_complete); + let server_capabilities = Arc::clone(&server_capabilities); + let tool_catalog_cache_context = tool_catalog_cache_context.clone(); + let client = async move { + let outcome = pool + .connect( + slot, + key, + server_capabilities, + tool_catalog_cache_context, + cancel_token, + |cancellation, sharing_declined| { + let mut shared_startup = (*startup).clone(); + shared_startup.cancel_token = cancellation; + shared_startup.start(sharing_declined) + }, + ) + .await + .map(|mut client| { + if matches!(client.connection_sharing, ConnectionSharing::Shared(_)) { + client.shared_elicitation = + Some(Arc::new(startup.elicitation_requests.make_sender( + startup.server_name.clone(), + startup.tx_event.clone(), + &startup.client_mcp_extensions, + ))); + } + client + }); + startup_complete.store(true, Ordering::Release); + outcome + } + .boxed() + .shared(); + (client, Some(connection_slot)) + } else { + (startup.start(/*sharing_declined*/ None), None) + }; let startup_reconnect = is_codex_apps_mcp_server.then(|| { let startup = Arc::clone(&startup); Arc::new( - CodexAppsStartupReconnect::new(Arc::new(move || startup.start())) - .with_startup_status_context( - startup_submit_id, - reconnect_server_name, - reconnect_tx_event, - ), + CodexAppsStartupReconnect::new(Arc::new(move || { + startup.start(/*sharing_declined*/ None) + })) + .with_startup_status_context( + startup_submit_id, + reconnect_server_name, + reconnect_tx_event, + ), ) }); Self { @@ -594,6 +610,7 @@ impl AsyncManagedClient { tool_catalog_cache_context, startup_complete, startup_reconnect, + connection_slot, cancel_token, } } @@ -669,13 +686,29 @@ impl AsyncManagedClient { pub(crate) async fn shutdown(&self) { self.cancel_token.cancel(); match self.client().await { - Ok(client) => client.client.shutdown().await, + Ok(client) => match &client.connection_sharing { + ConnectionSharing::Owned(lease) | ConnectionSharing::Shared(lease) => { + if let Some(slot) = lease.release() { + return slot.wait_for_shutdown().await; + } + if matches!(&client.connection_sharing, ConnectionSharing::Owned(_)) { + // Waiters from startup may still hold leases even though sharing was declined. + client.client.shutdown().await; + } + } + ConnectionSharing::Private | ConnectionSharing::Negotiated => { + client.client.shutdown().await; + } + }, Err(StartupOutcomeError::Cancelled) => {} Err(error) => { warn!("failed to initialize MCP client during shutdown: {error:#}"); error.wait_for_shutdown().await; } } + if let Some(slot) = &self.connection_slot { + slot.wait_for_shutdown().await; + } } pub(crate) fn has_cached_tools(&self) -> bool { @@ -1081,6 +1114,7 @@ async fn start_server_task( params: StartServerTaskParams, ) -> Result { let StartServerTaskParams { + sharing_declined, is_codex_apps_mcp_server, startup_timeout, tx_event, @@ -1098,6 +1132,12 @@ async fn start_server_task( elicitation_requests.make_sender(server_name.clone(), tx_event, &client_mcp_extensions); let client_supports_network_policy_meta = client_mcp_extensions.contains(MCP_NETWORK_POLICY_META_CAPABILITY); + let (sharing_negotiation, send_elicitation) = if let Some(sharing_declined) = sharing_declined { + let (negotiation, sender) = SharingNegotiation::new(send_elicitation, sharing_declined); + (Some(negotiation), sender) + } else { + (None, send_elicitation) + }; let mut params = mcp_initialize_request_params(client_elicitation_capability, client_mcp_extensions); if auth_changes.is_some() { @@ -1130,6 +1170,9 @@ async fn start_server_task( { context.invalidate_if_newest(ticket); } + let connection_sharing = sharing_negotiation + .map(|negotiation| negotiation.finish(&initialize_result.protocol_version)) + .unwrap_or_default(); *server_capabilities .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = @@ -1234,6 +1277,8 @@ async fn start_server_task( ); } let managed = ManagedClient { + connection_sharing, + shared_elicitation: None, _auth_change_notifications: auth_change_notifications, client: Arc::clone(&client), server_info, @@ -1338,6 +1383,7 @@ fn mcp_server_info_from_implementation( } struct StartServerTaskParams { + sharing_declined: Option, server_capabilities: Arc>>, is_codex_apps_mcp_server: bool, startup_timeout: Option, // TODO: cancel_token should handle this. diff --git a/codex-rs/codex-mcp/src/rmcp_client/managed_client.rs b/codex-rs/codex-mcp/src/rmcp_client/managed_client.rs new file mode 100644 index 000000000000..1749bf853d46 --- /dev/null +++ b/codex-rs/codex-mcp/src/rmcp_client/managed_client.rs @@ -0,0 +1,150 @@ +//! A chat's access to an initialized MCP client and its tool catalog. +//! A pooled connection honors this chat's lease and routes input requests back to it. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use super::MCP_TOOLS_LIST_DURATION_METRIC; +use crate::client_tool_catalog::ClientToolCatalog; +use crate::connection_pool::ConnectionSharing; +use crate::runtime::emit_duration; +use crate::tools::ToolInfo; +use anyhow::Result; +use anyhow::anyhow; +use codex_connectors::ConnectorRuntimeContext; +use codex_protocol::mcp::McpServerInfo; +use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::SendElicitation; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; +use rmcp::model::PaginatedRequestParams; +use rmcp::model::ServerCapabilities; +use serde_json::Value; +use tokio_util::task::AbortOnDropHandle; + +#[derive(Clone)] +pub(crate) struct ManagedClient { + pub(crate) connection_sharing: ConnectionSharing, + /// The calling chat's input handler, separate from the shared transport's startup handler. + pub(crate) shared_elicitation: Option>, + pub(crate) _auth_change_notifications: Option>>, + pub(crate) client: Arc, + pub(crate) server_info: McpServerInfo, + pub(crate) tool_catalog: Arc, + pub(crate) tool_timeout: Option, + pub(crate) server_instructions: Option, + pub(crate) server_supports_sandbox_state_meta_capability: bool, + pub(crate) client_supports_network_policy_meta_capability: bool, + pub(crate) codex_apps_tools_cache_context: Option>, +} + +impl ManagedClient { + pub(crate) async fn with_active_shared_lease( + &self, + operation: impl Future, + ) -> Result { + match &self.connection_sharing { + ConnectionSharing::Shared(lease) => tokio::select! { + biased; + () = lease.cancellation().cancelled() => { + Err(anyhow!("MCP connection lease has been released")) + } + result = operation => Ok(result), + }, + ConnectionSharing::Private + | ConnectionSharing::Negotiated + | ConnectionSharing::Owned(_) => Ok(operation.await), + } + } + + pub(crate) async fn call_tool( + &self, + name: String, + arguments: Option, + meta_for_session: impl Fn(&ServerCapabilities) -> Result> + Send + Sync, + timeout: Option, + ) -> Result { + self.with_active_shared_lease(async { + if let Some(send_elicitation) = &self.shared_elicitation { + self.client + .call_tool_with_elicitation( + name, + arguments, + meta_for_session, + timeout, + Arc::clone(send_elicitation), + ) + .await + } else { + self.client + .call_tool(name, arguments, meta_for_session, timeout) + .await + } + }) + .await? + } + + /// Lists resources within this chat's lease lifetime. + pub(crate) async fn list_resources( + &self, + params: Option, + timeout: Option, + ) -> Result { + self.with_active_shared_lease(self.client.list_resources(params, timeout)) + .await? + } + + /// Lists resource templates within this chat's lease lifetime. + pub(crate) async fn list_resource_templates( + &self, + params: Option, + timeout: Option, + ) -> Result { + self.with_active_shared_lease(self.client.list_resource_templates(params, timeout)) + .await? + } + + /// Reads a resource using this chat's input authority and lease lifetime. + pub(crate) async fn read_resource( + &self, + params: rmcp::model::ReadResourceRequestParams, + timeout: Option, + ) -> Result { + self.with_active_shared_lease(async { + if let Some(send_elicitation) = &self.shared_elicitation { + self.client + .read_resource_with_elicitation(params, timeout, Arc::clone(send_elicitation)) + .await + } else { + self.client.read_resource(params, timeout).await + } + }) + .await? + } + + pub(crate) async fn listed_catalog(&self) -> (Vec, Option>) { + let total_start = Instant::now(); + self.tool_catalog + .read(|catalog| { + // Discovery may use the shared cache until this client is refreshed. + // Executable bindings always capture this client's own catalog. + if catalog.revision == 0 + && let Some(cache_context) = &self.codex_apps_tools_cache_context + { + let tools = cache_context.current_catalog_snapshot(); + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", if tools.is_some() { "hit" } else { "miss" })], + ); + if let Some(tools) = tools { + return (tools.tools().to_vec(), tools.shared_tool_list_meta()); + } + } + (catalog.tools.to_vec(), catalog.meta) + }) + .await + } +} diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index e994fb0f382c..a9f1f09c6209 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -8,6 +8,10 @@ mod status; +#[cfg(test)] +#[path = "runtime_shared_connection_tests.rs"] +mod shared_connection_tests; + use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -46,6 +50,7 @@ use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; use tokio::sync::watch; use tokio_util::sync::CancellationToken; +use tracing::warn; use crate::McpConfig; use crate::binding::McpBinding; @@ -54,6 +59,7 @@ use crate::client_tool_catalog::CodexAppsToolSnapshot; use crate::connection_manager::BindingCatalogRevision; use crate::connection_manager::McpConnectionSet; use crate::connection_manager::PreviousMcpConnections; +use crate::connection_pool::McpConnectionPool; use crate::elicitation::ElicitationLifecycle; use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::ElicitationReviewerHandle; @@ -112,6 +118,8 @@ pub struct McpRuntimeInput { /// their exact connections and configuration for as long as they are needed. pub struct McpRuntime { current: ArcSwap, + // Old bindings keep leases alive after publication; cancellation alone does not release them. + superseded_connections: Mutex>>, event_stream_cancellation: Mutex, reconnect_pending: AtomicBool, resource_cache_generation: AtomicU64, @@ -269,6 +277,7 @@ impl McpRuntime { ready_environments: HashMap::new(), cached_binding: Mutex::new(None), }), + superseded_connections: Mutex::default(), event_stream_cancellation: Mutex::new(EventStreamCancellation { event_server_available: false, cancel_event_streams_on_server_removal: watch::channel(()).0, @@ -398,6 +407,9 @@ impl McpRuntime { let auth = input.auth.clone(); let auth_manager = input.auth_manager.clone(); let auth_token = auth.as_ref().and_then(|auth| auth.get_token().ok()); + if !reuse_previous { + current.connections.retire_shared_connections(); + } let auth_matches = current.auth_matches(auth.as_ref()); let auth_owner = AuthOwner::capture(auth.as_ref(), auth_manager.as_deref()); let same_owner = match (current.auth_owner, auth_owner) { @@ -421,8 +433,9 @@ impl McpRuntime { let ready_selected_capability_roots = input.ready_selected_capability_roots.clone(); let environment_selections = Arc::clone(&input.runtime_context.environment_selections); let ready_environments = input.runtime_context.ready_environments.clone(); + // Keep MCP startup state out of the caller's nested session-initialization future. let connections = Arc::new( - McpConnectionSet::new( + Box::pin(McpConnectionSet::new( ema_auth, retain_ema.then_some(current.connections.as_ref()), reuse_previous.then_some(PreviousMcpConnections { @@ -432,7 +445,7 @@ impl McpRuntime { publication_gate, input, self.elicitation_router.clone(), - ) + )) .await, ); let hosted_event_server_retained = connections.contains_server(CODEX_APPS_MCP_SERVER_NAME) @@ -448,6 +461,14 @@ impl McpRuntime { .event_stream_cancellation .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + { + let mut superseded = self + .superseded_connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + superseded.retain(|connections| connections.strong_count() > 0); + superseded.push(Arc::downgrade(¤t.connections)); + } self.current.store(Arc::new(PublishedMcpRuntime { connections, config: Some(config), @@ -923,7 +944,26 @@ impl McpRuntime { pub async fn shutdown(&self) { self.elicitation_router.close().await; - self.latest_connections().shutdown().await; + let superseded = std::mem::take( + &mut *self + .superseded_connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + let current = self.latest_connections(); + // Keep every publication's cleanup alive if the caller is interrupted. + let shutdown = tokio::spawn(async move { + for connections in superseded + .into_iter() + .filter_map(|connections| connections.upgrade()) + { + connections.shutdown_shared_connections().await; + } + current.shutdown().await; + }); + if let Err(error) = shutdown.await { + warn!("MCP runtime shutdown task failed: {error}"); + } } } @@ -933,6 +973,7 @@ impl McpRuntime { /// environment registry, captured thread selections, and the host-local cwd used by MCP processes. #[derive(Clone)] pub struct McpRuntimeContext { + pub(crate) connection_pool: Option, environment_manager: Arc, environment_selections: Arc<[TurnEnvironmentSelection]>, ready_environments: HashMap>, @@ -981,6 +1022,7 @@ impl McpRuntimeContext { .with_tls_backend_fallback(), ); Self { + connection_pool: None, environment_manager, environment_selections: Arc::default(), ready_environments: HashMap::new(), @@ -989,6 +1031,12 @@ impl McpRuntimeContext { } } + /// Uses this process's pool for compatible local stdio servers using MCP 2026-07-28. + pub fn with_connection_pool(mut self, pool: McpConnectionPool) -> Self { + self.connection_pool = Some(pool); + self + } + /// Pins all captured selections and the concrete handles for environments already ready. pub fn with_selected_environments( mut self, diff --git a/codex-rs/codex-mcp/src/runtime_shared_connection_tests.rs b/codex-rs/codex-mcp/src/runtime_shared_connection_tests.rs new file mode 100644 index 000000000000..812bbcc491ef --- /dev/null +++ b/codex-rs/codex-mcp/src/runtime_shared_connection_tests.rs @@ -0,0 +1,172 @@ +use super::*; +use crate::connection_manager::tests::create_ready_async_managed_client; +use crate::connection_pool::ConnectionSharing; +use crate::connection_pool::ConnectionSlot; +use crate::connection_pool::McpConnectionPool; +use crate::connection_pool::tests as fixture; +use futures::FutureExt; +use pretty_assertions::assert_eq; +use std::sync::atomic::AtomicUsize; +use tokio::sync::Notify; + +#[tokio::test] +async fn forced_refresh_replaces_pending_connection_without_interrupting_other_chats() { + let pool = McpConnectionPool::default(); + let key = fixture::key(&fixture::context(), &fixture::config()); + let starts = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let launch = Arc::new(fixture::startup( + ConnectionSharing::Negotiated, + Arc::clone(&starts), + Arc::clone(&started), + Arc::clone(&release), + fixture::TestServer::default(), + )); + let slot = Arc::new(ConnectionSlot::default()); + let first = { + let pool = pool.clone(); + let key = key.clone(); + let slot = Arc::clone(&slot); + let launch = Arc::clone(&launch); + async move { + pool.connect( + slot, + key, + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &*launch, + ) + .await + } + .boxed() + .shared() + }; + let mut poll_first = first.clone(); + assert!(futures::poll!(&mut poll_first).is_pending()); + let other = pool.connect( + Arc::default(), + key.clone(), + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &*launch, + ); + tokio::pin!(other); + assert!(futures::poll!(&mut other).is_pending()); + started.notified().await; + let mut owner = create_ready_async_managed_client(Vec::new()).await; + owner.client = first; + owner.connection_slot = Some(slot); + let mut connections = McpConnectionSet::empty(/*prefix_mcp_tool_names*/ true); + connections.insert_test_client("test_server", owner); + let connections = Arc::new(connections); + let config = Arc::new(crate::mcp::tests::test_mcp_config(std::env::temp_dir())); + let runtime = McpRuntime::empty(/*prefix_mcp_tool_names*/ true); + runtime.current.store(Arc::new(PublishedMcpRuntime { + connections: Arc::clone(&connections), + config: Some(Arc::clone(&config)), + auth: None, + auth_manager: None, + auth_token: None, + auth_owner: None, + auth_generation: Arc::new(()), + plugins_available: false, + ready_selected_capability_roots: Vec::new(), + environment_selections: Arc::default(), + ready_environments: HashMap::new(), + cached_binding: Mutex::new(None), + })); + let input = McpRuntimeInput { + network_policy_metadata: None, + startup_policy: McpStartupPolicy::Eager, + config, + plugins_available: false, + ready_selected_capability_roots: Vec::new(), + mcp_servers: HashMap::new(), + submit_id: "refresh".to_string(), + tx_event: None, + startup_cancellation_token: CancellationToken::new(), + runtime_context: fixture::context(), + codex_apps_tools_cache: ConnectorRuntimeManager::default(), + tool_catalog_cache: McpToolCatalogCache::default(), + codex_apps_tools_cache_key: ConnectorRuntimeContextKey::personal( + /*account_id*/ None, /*chatgpt_user_id*/ None, + ), + client_mcp_extensions: ClientMcpExtensions::default(), + auth: None, + auth_manager: None, + elicitation_reviewer: None, + elicitation_lifecycle: None, + }; + let binding = runtime.current_binding_for_call("test_server"); + tokio::pin!(binding); + assert!(futures::poll!(&mut binding).is_pending()); + runtime.reconnect_on_next_refresh(); + runtime.replace(input).await; + let _replacement = tokio::time::timeout( + Duration::from_secs(1), + pool.connect( + Arc::default(), + key.clone(), + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &*launch, + ), + ) + .await + .expect("forced refresh must not wait for the old startup") + .unwrap(); + release.notify_one(); + let other = other.await.unwrap(); + other + .call_tool( + "test".to_string(), + /*arguments*/ None, + |_| Ok(None), + /*timeout*/ None, + ) + .await + .unwrap(); + + // A second refresh of the old owner must not evict the newer replacement. + connections.retire_shared_connections(); + let _joined = pool + .connect( + Arc::default(), + key, + Arc::default(), + /*tool_catalog_cache_context*/ None, + CancellationToken::new(), + &*launch, + ) + .await + .unwrap(); + assert_eq!(starts.load(Ordering::SeqCst), 2); + + let binding = binding.await.expect("binding from the old publication"); + runtime.shutdown().await; + assert!( + binding + .list_resources("test_server", /*params*/ None) + .await + .is_err() + ); + other + .call_tool( + "test".to_string(), + /*arguments*/ None, + |_| Ok(None), + /*timeout*/ None, + ) + .await + .unwrap(); + let transport = Arc::clone(&other.client); + let mut other_owner = create_ready_async_managed_client(Vec::new()).await; + other_owner.client = futures::future::ready(Ok(other)).boxed().shared(); + other_owner.shutdown().await; + assert!(transport.is_closed().await); + drop(binding); +} diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs index f4170cb011aa..3bfa3b412c42 100644 --- a/codex-rs/core/src/mcp.rs +++ b/codex-rs/core/src/mcp.rs @@ -111,6 +111,7 @@ enum OrderedMcpOverlay { #[derive(Clone)] pub struct McpManager { + connection_pool: codex_mcp::McpConnectionPool, plugins_manager: Arc, extensions: Arc>, codex_apps_tools_cache: ConnectorRuntimeManager, @@ -135,6 +136,7 @@ impl McpManager { codex_apps_tools_cache: ConnectorRuntimeManager, ) -> Self { Self { + connection_pool: codex_mcp::McpConnectionPool::default(), plugins_manager, extensions, codex_apps_tools_cache, @@ -152,6 +154,10 @@ impl McpManager { self.tool_catalog_cache.clone() } + pub(crate) fn connection_pool(&self) -> codex_mcp::McpConnectionPool { + self.connection_pool.clone() + } + pub(crate) fn shared_definitions(&self) -> &SharedMcpDefinitions { &self.shared_definitions } diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 895770147c12..b1d8e2a9b448 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -175,7 +175,7 @@ impl Session { self.mcp_runtime_context(&environments, &host_fallback_cwd) } - fn mcp_runtime_context( + pub(super) fn mcp_runtime_context( &self, environments: &TurnEnvironmentSnapshot, host_fallback_cwd: &std::path::Path, @@ -188,6 +188,7 @@ impl Session { self.services.turn_environments.environment_manager(), local_process_cwd, ) + .with_connection_pool(self.services.mcp_manager.connection_pool()) .with_selected_environments( environments.all_selections().into(), environments.ready_environment_handles(), diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index d00bb6af4135..ac0bc4a98375 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -358,14 +358,8 @@ impl Session { }), ); let mcp_config = Arc::new(config); - let runtime_context = McpRuntimeContext::new( - self.services.turn_environments.environment_manager(), - desired.local_process_cwd.clone(), - ) - .with_selected_environments( - desired.environments.all_selections().into(), - desired.environments.ready_environment_handles(), - ); + let runtime_context = + self.mcp_runtime_context(&desired.environments, &desired.local_process_cwd); McpRuntimeInput { network_policy_metadata: None, startup_policy: if matches!(desired.session_source, SessionSource::SubAgent(_)) { diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index afe1c89c1669..a3a7a47e3ea6 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -152,6 +152,9 @@ mod mcp_resource_messages; #[path = "scenarios_mcp_ema.rs"] mod mcp_ema; +#[path = "scenarios_mcp_sharing.rs"] +mod mcp_sharing; + #[path = "scenarios_guardian_agent_messages_tests.rs"] mod guardian_agent_messages; diff --git a/codex-rs/core/tests/suite/scenarios_mcp_sharing.rs b/codex-rs/core/tests/suite/scenarios_mcp_sharing.rs new file mode 100644 index 000000000000..54b7aebe9543 --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_mcp_sharing.rs @@ -0,0 +1,179 @@ +//! Shared MCP transports preserve each chat's model-visible tools and input responses. + +use super::*; +use codex_protocol::approvals::ElicitationAction; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ThreadSettingsOverrides; +use core_test_support::responses; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shared_mcp_keeps_tools_and_model_requested_input_per_chat() -> Result<()> { + skip_if_no_network!(Ok(())); + core_test_support::skip_if_remote!(Ok(()), "the shared stdio fixture runs on the host"); + skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); + + let first_server = start_mock_server().await; + let second_server = start_mock_server().await; + let command = stdio_server_bin()?; + let test = test_codex() + .with_config(move |config| { + super::configure_scenario_catalog(config); + config.approvals_reviewer = ApprovalsReviewer::User; + config + .features + .enable(Feature::McpConnectionSharing) + .expect("enable sharing"); + config + .features + .disable(Feature::Mcp20260728) + .expect("let sharing select the protocol"); + config + .mcp_servers + .set( + serde_json::from_value(json!({ + "shared": { + "command": command, + "enabled_tools": ["echo"], + "default_tools_approval_mode": "approve", + "env": { + "MCP_TEST_ECHO_INPUT": "1", + }, + }, + })) + .expect("shared MCP config"), + ) + .expect("set MCP config"); + }) + .with_model_info_override("gpt-5.5", |model| { + model.tool_mode = Some(ToolMode::Direct); + model.supports_search_tool = false; + model.use_responses_lite = false; + }) + .build_with_auto_env(&first_server) + .await?; + wait_for_mcp_server(&test.codex, "shared").await?; + + let mut options = test.start_thread_options().await; + options.config.model_provider.base_url = Some(format!("{}/v1", second_server.uri())); + let mut servers = options.config.mcp_servers.get().clone(); + servers + .get_mut("shared") + .expect("shared server") + .enabled_tools = Some(vec!["echo-tool".to_string()]); + options.config.mcp_servers.set(servers)?; + let second = test.thread_manager.start_thread(options).await?.thread; + wait_for_mcp_server(&second, "shared").await?; + + let chats = [ + ( + &test.codex, + &first_server, + "echo", + "First task owner?", + "Mira", + ), + ( + &second, + &second_server, + "echo_tool", + "Second task owner?", + "Sora", + ), + ]; + let mut mocks = Vec::new(); + for (thread, server, tool, question, _) in chats { + mocks.push( + mount_sse_sequence( + server, + vec![ + sse(vec![ + ev_response_created("ask-owner"), + ev_function_call_with_namespace( + "ask-owner", + "mcp__shared", + tool, + &json!({ "message": question }).to_string(), + ), + ev_completed("ask-owner"), + ]), + responses::sse_completed("done"), + ], + ) + .await, + ); + thread + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: format!("Ask the user: {question}"), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::OnRequest), + permission_profile: Some(PermissionProfile::read_only()), + ..Default::default() + }), + ) + .await?; + } + + let mut inputs = Vec::new(); + let mut processes = Vec::new(); + for (thread, _, _, question, _) in chats { + let input = wait_for_event_match(thread, |event| match event { + EventMsg::ElicitationRequest(input) => Some(input.clone()), + _ => None, + }) + .await; + let ElicitationRequest::Form { meta, message, .. } = &input.request else { + panic!("expected a form input"); + }; + assert_eq!(message, question); + processes.push(meta.as_ref().expect("fixture metadata")["fixture/processId"].clone()); + inputs.push(input); + } + assert!(processes[0].is_u64()); + assert_eq!( + processes[0], processes[1], + "the chats must share one process" + ); + + // Both calls use the same server input key; resolve the joiner's input first. + for ((thread, _, _, _, answer), input) in chats.into_iter().zip(inputs).rev() { + thread + .submit(Op::ResolveElicitation { + server_name: input.server_name, + request_id: input.id, + decision: ElicitationAction::Accept, + content: Some(json!({ "answer": answer })), + meta: None, + }) + .await?; + } + + let mut histories = Vec::new(); + for ((thread, ..), mock) in chats.into_iter().zip(mocks) { + wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; + histories.push(mock.requests()); + } + let entries = histories + .iter() + .zip(["creator", "joiner"]) + .flat_map(|(requests, label)| { + requests + .iter() + .map(move |request| SnapshotEntry::captured(request).labeled(label)) + }) + .collect::>(); + insta::assert_snapshot!(context_snapshot::format_context_snapshot( + "Two chats share one MCP process, expose different tools, and answer simultaneous model-requested questions in reverse order.", + &entries, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + )); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mcp_sharing__shared_mcp_keeps_tools_and_model_requested_input_per_chat.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mcp_sharing__shared_mcp_keeps_tools_and_model_requested_input_per_chat.snap new file mode 100644 index 000000000000..b007c48c7765 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mcp_sharing__shared_mcp_keeps_tools_and_model_requested_input_per_chat.snap @@ -0,0 +1,68 @@ +--- +source: core/tests/suite/scenarios_mcp_sharing.rs +expression: "context_snapshot::format_context_snapshot(\"Two chats share one MCP process, expose different tools, and answer simultaneous model-requested questions in reverse order.\",\n&entries,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: Two chats share one MCP process, expose different tools, and answer simultaneous model-requested questions in reverse order. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.5" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=e4ad6947d6c88cac): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__shared: Use these tools to exercise the rmcp test server.; hash=c2583dfa504bbe3f + - function/echo + - web_search; hash=f103e327c6870a46 +-- request 1 (turn; creator) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Ask the user: First task owner? +-- request 2 (turn; creator) -- +04:function_call/mcp__shared.echo:{"message":"First task owner?"} +05:function_call_output:Wall time: seconds + Output: | Mira + +## Window 2 (after request 2: input diverged at item 03) +Settings: relative to window 1 + prompt_cache_key: "" + tools (11; hash=b0f271ad4e160a41): + ~ namespace/mcp__shared: Use these tools to exercise the rmcp test server.; hash=a9e155b340098fac + - function/echo_tool +-- request 3 (turn; joiner) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Ask the user: Second task owner? +-- request 4 (turn; joiner) -- +04:function_call/mcp__shared.echo_tool:{"message":"Second task owner?"} +05:function_call_output:Wall time: seconds + Output: | Sora diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 5fd61d1445d4..83daf42efc74 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -269,7 +269,7 @@ pub enum Feature { Mcp20260728, /// Honor ordinary MCP server tool-list notifications within a turn. McpToolListChanged, - /// Discover the MCP protocol for eligible local stdio servers. + /// Discover and share eligible local stdio connections across chats. McpConnectionSharing, /// Enable MCP protocol version 2026-07-28 for the host-owned Codex Apps server. CodexAppsMcp20260728, diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 8ea0150268a6..83780e2a4003 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -13,9 +13,15 @@ use rmcp::ServiceExt; use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; +use rmcp::model::ContentBlock; +use rmcp::model::ElicitRequest; +use rmcp::model::ElicitRequestParams; +use rmcp::model::ElicitationSchema; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; use rmcp::model::InitializeResult; +use rmcp::model::InputRequest; +use rmcp::model::InputRequiredResult; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; @@ -781,6 +787,30 @@ impl ServerHandler for TestToolServer { } }; + if std::env::var_os("MCP_TEST_ECHO_INPUT").is_some() { + if let Some(responses) = request.input_responses { + let answer = responses["confirmation"]["content"]["answer"] + .as_str() + .ok_or_else(|| McpError::invalid_params("missing answer text", None))?; + return Ok(CallToolResult::success(vec![ContentBlock::text(answer)]).into()); + } + let mut meta = MetaObject::new(); + meta.insert("fixture/processId".into(), json!(std::process::id())); + let form = ElicitRequestParams::FormElicitationParams { + meta: Some(meta.into()), + message: args.message, + requested_schema: ElicitationSchema::builder() + .required_string_property("answer", |s| s) + .build() + .map_err(|error| McpError::internal_error(error.to_string(), None))?, + }; + let input = InputRequest::Elicitation(ElicitRequest::new(form)); + return Ok(InputRequiredResult::from_input_requests(BTreeMap::from([ + ("confirmation".into(), input), + ])) + .into()); + } + let env_snapshot: HashMap = std::env::vars().collect(); let env_name = args.env_var.as_deref().unwrap_or("MCP_TEST_VALUE"); let echo = dynamic_server_process_label() From dd5521406a801212f7742432bcacb06e91823b01 Mon Sep 17 00:00:00 2001 From: Chris Ayoub Date: Sun, 25 Oct 2026 00:04:54 +0000 Subject: [PATCH 039/287] Enable zstd compression for code-mode gRPC responses (#57212) ## What changed - Configure the code-mode host to send zstd-compressed responses when clients advertise support, and enable both client transports to accept them. - Preserve uncompressed responses for clients that do not advertise zstd support. - Patch `tonic` in Bazel builds to use zstd compression level 1, favoring response latency over compression ratio. ## Testing Extend the TCP session integration test to check response encoding and successful session opening for both zstd-capable and uncompressed-only clients. GitOrigin-RevId: b6cf0da51dd30fc2ac9a1b0af11bec26555c559d --- MODULE.bazel | 8 +++ codex-rs/Cargo.lock | 1 + codex-rs/code-mode-host/Cargo.toml | 2 +- codex-rs/code-mode-host/src/grpc_transport.rs | 1 + codex-rs/code-mode-host/tests/grpc_tcp.rs | 52 ++++++++++++------- codex-rs/code-mode/Cargo.toml | 2 +- .../code-mode/src/grpc_session/transport.rs | 2 + patches/BUILD.bazel | 1 + patches/tonic-zstd-level1.patch | 14 +++++ 9 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 patches/tonic-zstd-level1.patch diff --git a/MODULE.bazel b/MODULE.bazel index 3ffde1809c4a..3157588c37c1 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -393,6 +393,14 @@ crate.annotation( }, rustc_flags = ["-Copt-level=3"], ) + +# Match the CodeModeHost response-compression benchmark's zstd level. +crate.annotation( + crate = "tonic", + patch_args = ["-p1"], + patches = ["//patches:tonic-zstd-level1.patch"], + version = "0.14.3", +) crate.annotation( crate = "zstd-sys", gen_build_script = "on", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dfa360b42a13..c28b3752ea2e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -15931,6 +15931,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "zstd", ] [[package]] diff --git a/codex-rs/code-mode-host/Cargo.toml b/codex-rs/code-mode-host/Cargo.toml index d56661f7d0ad..4b0d51c1a9f6 100644 --- a/codex-rs/code-mode-host/Cargo.toml +++ b/codex-rs/code-mode-host/Cargo.toml @@ -32,7 +32,7 @@ serde_json = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "net", "process", "rt", "sync", "time"] } tokio-stream = { workspace = true } tokio-util = { workspace = true, features = ["rt"] } -tonic = { workspace = true, features = ["router", "transport"] } +tonic = { workspace = true, features = ["router", "transport", "zstd"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } uuid = { workspace = true, features = ["v4"] } diff --git a/codex-rs/code-mode-host/src/grpc_transport.rs b/codex-rs/code-mode-host/src/grpc_transport.rs index 4cb59985fa0f..27e49456f07a 100644 --- a/codex-rs/code-mode-host/src/grpc_transport.rs +++ b/codex-rs/code-mode-host/src/grpc_transport.rs @@ -34,6 +34,7 @@ pub(super) async fn run_tcp_listener(bind_address: SocketAddr) -> Result<()> { let routes = Routes::new( CodeModeHostServer::new(GrpcCodeModeHost::new()) + .send_compressed(tonic::codec::CompressionEncoding::Zstd) .max_decoding_message_size(MAX_FRAME_BYTES) .max_encoding_message_size(MAX_FRAME_BYTES), ) diff --git a/codex-rs/code-mode-host/tests/grpc_tcp.rs b/codex-rs/code-mode-host/tests/grpc_tcp.rs index b15699b9447a..a9951195487e 100644 --- a/codex-rs/code-mode-host/tests/grpc_tcp.rs +++ b/codex-rs/code-mode-host/tests/grpc_tcp.rs @@ -117,6 +117,7 @@ async fn http1_response(endpoint: &str, request: &[u8]) -> Result { .context("timed out waiting for HTTP/1.1 response from gRPC code-mode host")? } +/// Negotiate zstd for capable clients while preserving identity-only clients. #[tokio::test] async fn tcp_listener_opens_a_grpc_session() -> Result<()> { let mut host = Command::new(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?) @@ -139,24 +140,37 @@ async fn tcp_listener_opens_a_grpc_session() -> Result<()> { .context("gRPC code-mode host published an invalid endpoint")? .connect_timeout(TEST_TIMEOUT) .timeout(TEST_TIMEOUT); - let mut client = CodeModeHostClient::connect(endpoint) - .await - .context("failed to connect to gRPC code-mode host")?; - let mut events = client - .open_session(grpc::OpenSessionRequest { - cell_execution_limits: None, - }) - .await - .context("failed to open gRPC code-mode session")? - .into_inner(); - let event = timeout(TEST_TIMEOUT, events.message()) - .await - .context("timed out waiting for gRPC code-mode session event")? - .context("failed to read gRPC code-mode session event")? - .context("gRPC code-mode session ended before opening")?; - assert!(matches!( - event.event, - Some(grpc::session_event::Event::Opened(opened)) if !opened.session_id.is_empty() - )); + for encoding in [None, Some(tonic::codec::CompressionEncoding::Zstd)] { + let mut client = CodeModeHostClient::connect(endpoint.clone()) + .await + .context("failed to connect to gRPC code-mode host")?; + if let Some(encoding) = encoding { + client = client.accept_compressed(encoding); + } + let response = client + .open_session(grpc::OpenSessionRequest { + cell_execution_limits: None, + }) + .await + .context("failed to open gRPC code-mode session")?; + assert_eq!( + response + .metadata() + .get("grpc-encoding") + .map(|value| value.to_str()) + .transpose()?, + encoding.map(|_| "zstd"), + ); + let mut events = response.into_inner(); + let event = timeout(TEST_TIMEOUT, events.message()) + .await + .context("timed out waiting for gRPC code-mode session event")? + .context("failed to read gRPC code-mode session event")? + .context("gRPC code-mode session ended before opening")?; + assert!(matches!( + event.event, + Some(grpc::session_event::Event::Opened(opened)) if !opened.session_id.is_empty() + )); + } Ok(()) } diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index daa2de547182..52cdae6d08cb 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -26,7 +26,7 @@ reqwest = { workspace = true, features = ["stream"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] } tokio-util = { workspace = true, features = ["rt"] } -tonic = { workspace = true } +tonic = { workspace = true, features = ["zstd"] } tower = { version = "0.5.3", features = ["util"] } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } diff --git a/codex-rs/code-mode/src/grpc_session/transport.rs b/codex-rs/code-mode/src/grpc_session/transport.rs index 0f9b336da1ac..f16a3fef8888 100644 --- a/codex-rs/code-mode/src/grpc_session/transport.rs +++ b/codex-rs/code-mode/src/grpc_session/transport.rs @@ -42,6 +42,7 @@ impl SharedTransport { let transport = channel.map_err(|error| Status::from_error(Box::new(error))); Self::Connected( CodeModeHostClient::new(BoxCloneSyncService::new(transport)) + .accept_compressed(tonic::codec::CompressionEncoding::Zstd) .max_decoding_message_size(MAX_FRAME_BYTES) .max_encoding_message_size(MAX_FRAME_BYTES), ) @@ -132,6 +133,7 @@ impl SharedTransport { CodeModeHostClient::with_origin(BoxCloneSyncService::new(transport), origin) }; let client = client + .accept_compressed(tonic::codec::CompressionEncoding::Zstd) .max_decoding_message_size(MAX_FRAME_BYTES) .max_encoding_message_size(MAX_FRAME_BYTES); *cached = Some((bound_factory, client.clone())); diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index 1c62ed89f26b..715f68299a1d 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -24,6 +24,7 @@ exports_files([ "ring_windows_msvc_include_dirs.patch", "tikv-jemalloc-sys_make_env.patch", "tikv-jemalloc-sys_dependency_flags.patch", + "tonic-zstd-level1.patch", "v8_array_sort_elements_kind.patch", "v8_source_portability.patch", "webrtc-sys_hermetic_darwin_sysroot.patch", diff --git a/patches/tonic-zstd-level1.patch b/patches/tonic-zstd-level1.patch new file mode 100644 index 000000000000..3b665899072a --- /dev/null +++ b/patches/tonic-zstd-level1.patch @@ -0,0 +1,14 @@ +--- a/src/codec/compression.rs ++++ b/src/codec/compression.rs +@@ -239,8 +239,9 @@ + CompressionEncoding::Zstd => { + let mut zstd_encoder = Encoder::new( + &decompressed_buf[0..len], +- // FIXME: support customizing the compression level +- zstd::DEFAULT_COMPRESSION_LEVEL, ++ // Favor CodeModeHost response latency over compression ratio. ++ // Level 1 is the fastest positive level and matches the benchmark. ++ 1, + )?; + std::io::copy(&mut zstd_encoder, &mut out_writer)?; + } From 9756b4937926e1202a4dcaafe8b0f7ba4e7dbb80 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Sun, 25 Oct 2026 00:33:24 +0000 Subject: [PATCH 040/287] Coordinate resume picker sources independently (#57215) ## What changed - Add a source coordinator that streams rows as backends reply, preserves each source's pagination cursor and listing mode, and rejects stale replies. - Route previews, transcripts, archive, and restore requests to the selected row's backend. - Keep healthy results visible when a source fails, and fall back from an empty or failed initial database listing per source. - Cancel reads when the picker closes while allowing accepted mutations to finish. The app-server picker now uses the coordinator with a single source. ## Testing Add regression coverage for independent source replies, pagination and fallback, action ownership, stale replies, disconnected sources, and mutation completion after picker shutdown. Add a snapshot for the partial-backend failure warning. GitOrigin-RevId: 1b565c680133d460f4a29c9946a8398e9c950773 --- codex-rs/tui/src/resume_picker.rs | 181 ++++++--- codex-rs/tui/src/resume_picker/archive.rs | 14 +- .../tui/src/resume_picker/archive_tests.rs | 29 +- codex-rs/tui/src/resume_picker/catalog.rs | 19 +- .../tui/src/resume_picker/catalog_tests.rs | 41 +- .../tui/src/resume_picker/page_loading.rs | 6 +- ...urces__tests__partial_backend_failure.snap | 6 + codex-rs/tui/src/resume_picker/sources.rs | 194 +++++++++ .../tui/src/resume_picker/sources_tests.rs | 367 ++++++++++++++++++ .../resume_picker_pagination_error_tests.rs | 2 + 10 files changed, 782 insertions(+), 77 deletions(-) create mode 100644 codex-rs/tui/src/resume_picker/snapshots/codex_tui__resume_picker__sources__tests__partial_backend_failure.snap create mode 100644 codex-rs/tui/src/resume_picker/sources.rs create mode 100644 codex-rs/tui/src/resume_picker/sources_tests.rs diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index e3f1072d1d41..c054f23d1dfe 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -76,6 +76,7 @@ mod archive; mod catalog; mod layout; mod page_loading; +mod sources; #[cfg(test)] #[path = "resume_picker_color_tests.rs"] @@ -197,16 +198,20 @@ struct PageLoadRequest { enum PickerLoadRequest { Page(PageLoadRequest), Preview { + backend_id: crate::backend_id::BackendId, thread_id: ThreadId, }, Transcript { + backend_id: crate::backend_id::BackendId, thread_id: ThreadId, cancellation: oneshot::Receiver<()>, }, Archive { + backend_id: crate::backend_id::BackendId, thread_id: ThreadId, }, Unarchive { + backend_id: crate::backend_id::BackendId, thread_id: ThreadId, }, } @@ -322,10 +327,24 @@ impl From for SessionPickerViewMode { } type PickerLoader = Arc; + +struct PickerBackground { + loader: PickerLoader, + task: tokio::task::JoinHandle<()>, +} + enum BackgroundEvent { + PartialPage { + request_token: usize, + page: PickerPage, + }, + SourceError { + request_token: usize, + }, Page { request_token: usize, search_token: Option, + mode: PageLoadMode, page: std::io::Result, }, Preview { @@ -349,6 +368,7 @@ enum BackgroundEvent { #[derive(Clone)] enum PageCursor { AppServer(String), + Catalog, } struct PickerPage { @@ -582,6 +602,19 @@ pub async fn run_fork_picker_with_app_server( } async fn run_session_picker_with_loader( + tui: &mut Tui, + options: SessionPickerRunOptions, + background: PickerBackground, + bg_rx: mpsc::UnboundedReceiver, +) -> Result { + let PickerBackground { loader, task } = background; + let result = run_session_picker_event_loop(tui, options, loader, bg_rx).await; + + drop(task); + result +} + +async fn run_session_picker_event_loop( tui: &mut Tui, options: SessionPickerRunOptions, picker_loader: PickerLoader, @@ -752,33 +785,31 @@ fn spawn_app_server_page_loader( include_non_interactive: bool, raw_reasoning_visibility: RawReasoningVisibility, config: Option, - bg_tx: mpsc::UnboundedSender, -) -> PickerLoader { + output: mpsc::UnboundedSender, +) -> PickerBackground { + let backend_id = app_server.backend_id(); + let (bg_tx, events) = mpsc::unbounded_channel(); let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); - tokio::spawn(async move { + let task = tokio::spawn(async move { let mut app_server = app_server; let mut page_cwd_filter = PageCwdFilter::default(); - let mut catalog = catalog::ThreadCatalog::default(); while let Some(request) = request_rx.recv().await { match request { PickerLoadRequest::Page(request) => { - if request.cursor.is_none() { - catalog.reset(); - } - let Some(catalog_request) = catalog.begin_page(app_server.backend_id()) else { - continue; - }; + let mode = request.mode; let cwd_filter = page_cwd_filter.for_request( request.cursor.as_ref(), request.cwd_filter.as_deref(), request.filter_mode, uses_remote_filesystem, ); - let cursor = catalog_request - .cursor - .clone() - .map(|PageCursor::AppServer(cursor)| cursor); + let cursor = request.cursor.map(|cursor| match cursor { + PageCursor::AppServer(cursor) => cursor, + PageCursor::Catalog => { + unreachable!("catalog cursors never reach a source") + } + }); let params = thread_list_params( cursor, cwd_filter, @@ -788,24 +819,28 @@ fn spawn_app_server_page_loader( include_non_interactive, matches!(request.mode, PageLoadMode::StateDbOnly), ); - let page = load_app_server_page(&mut app_server, params).await; - let Some(page) = catalog.finish_page(catalog_request, page) else { - continue; + let page = tokio::select! { + page = load_app_server_page(&mut app_server, params) => page, + _ = bg_tx.closed() => continue, }; let _ = bg_tx.send(BackgroundEvent::Page { request_token: request.request_token, search_token: request.search_token, + mode, page, }); } - PickerLoadRequest::Preview { thread_id } => { - let preview = - load_transcript_preview(&mut app_server, thread_id, config.as_ref()).await; + PickerLoadRequest::Preview { thread_id, .. } => { + let preview = tokio::select! { + preview = load_transcript_preview(&mut app_server, thread_id, config.as_ref()) => preview, + _ = bg_tx.closed() => continue, + }; let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview }); } PickerLoadRequest::Transcript { thread_id, cancellation, + .. } => { tokio::select! { transcript = load_session_transcript( @@ -820,9 +855,10 @@ fn spawn_app_server_page_loader( }); } _ = cancellation => {} + _ = bg_tx.closed() => {} } } - PickerLoadRequest::Archive { thread_id } => { + PickerLoadRequest::Archive { thread_id, .. } => { let result = archive_request_handle .request_typed::(ClientRequest::ThreadArchive { request_id: RequestId::String(format!( @@ -838,7 +874,7 @@ fn spawn_app_server_page_loader( .map_err(std::io::Error::other); let _ = bg_tx.send(BackgroundEvent::Archive { thread_id, result }); } - PickerLoadRequest::Unarchive { thread_id } => { + PickerLoadRequest::Unarchive { thread_id, .. } => { let result = archive_request_handle .request_typed::(ClientRequest::ThreadUnarchive { request_id: RequestId::String(format!( @@ -867,9 +903,15 @@ fn spawn_app_server_page_loader( } }); - Arc::new(move |request: PickerLoadRequest| { - let _ = request_tx.send(request); - }) + let source = sources::Source { + backend_id, + loader: Arc::new(move |request: PickerLoadRequest| { + let _ = request_tx.send(request); + }), + events, + task, + }; + sources::spawn(vec![source], output) } /// Returns the human-readable column header for the given sort key. @@ -1262,6 +1304,7 @@ impl PickerState { self.request_frame(); return; }; + let backend_id = row.backend_id.unwrap_or(self.backend_id); match self.transcript_cells.get(&thread_id) { Some(SessionTranscriptState::Loaded(_)) => { self.begin_transcript_loading(thread_id); @@ -1276,6 +1319,7 @@ impl PickerState { let (cancellation_tx, cancellation) = oneshot::channel(); self.pending_transcript_cancellation = Some(cancellation_tx); (self.picker_loader)(PickerLoadRequest::Transcript { + backend_id, thread_id, cancellation, }); @@ -1385,7 +1429,7 @@ impl PickerState { let path = row.path.clone(); if let Some(thread_id) = row.thread_id { if self.status == SessionStatus::Archived { - self.request_unarchive(thread_id); + self.request_unarchive(thread_id, row.backend_id.unwrap_or(self.backend_id)); return Ok(None); } return Ok(Some(self.action.selection(SessionTarget { @@ -1569,20 +1613,36 @@ impl PickerState { event: BackgroundEvent, ) -> Result> { match event { + BackgroundEvent::PartialPage { + request_token, + page, + } => { + if self.pagination.is_pending(request_token) { + self.ingest_page(page); + } + } + BackgroundEvent::SourceError { request_token } => { + if self.pagination.is_pending(request_token) { + self.inline_error = + Some("Could not load one backend. Reopen the picker to retry.".into()); + self.request_frame(); + } + } BackgroundEvent::Page { request_token, search_token, + mode, page, } => { let Some(pending) = self.pagination.finish_load(request_token) else { return Ok(None); }; + self.pagination.next_page_mode = mode; let page_has_rows = matches!(&page, Ok(page) if !page.rows.is_empty()); // Fall back only when the initial DB listing is unusable. Once SQLite returns // rows, its pagination is authoritative and an empty later page ends the list. - let should_restart_from_store = pending.mode == PageLoadMode::StateDbOnly - && self.all_rows.is_empty() - && !page_has_rows; + let should_restart_from_store = + mode == PageLoadMode::StateDbOnly && self.all_rows.is_empty() && !page_has_rows; if should_restart_from_store { let request_token = self.allocate_request_token(); let search_token = pending.search_token.or(search_token); @@ -2048,6 +2108,7 @@ impl PickerState { let Some(thread_id) = row.thread_id else { return; }; + let backend_id = row.backend_id.unwrap_or(self.backend_id); if self.expanded_thread_id == Some(thread_id) { self.expanded_thread_id = None; self.request_frame(); @@ -2058,7 +2119,10 @@ impl PickerState { self.transcript_previews.entry(thread_id) { e.insert(TranscriptPreviewState::Loading); - (self.picker_loader)(PickerLoadRequest::Preview { thread_id }); + (self.picker_loader)(PickerLoadRequest::Preview { + backend_id, + thread_id, + }); } self.request_frame(); } @@ -3754,6 +3818,7 @@ mod tests { .handle_background_event(BackgroundEvent::Page { request_token: request.request_token, search_token: request.search_token, + mode: request.mode, page, }) .await @@ -3974,7 +4039,7 @@ mod tests { let mut live_picker = PickerState::new( crate::backend_id::BackendId::new(), FrameRequester::test_dummy(), - loader, + loader.loader.clone(), ProviderFilter::MatchDefault(config.model_provider_id.clone()), /*show_all*/ false, Some(primary.clone()), @@ -3982,14 +4047,16 @@ mod tests { ); live_picker.local_filter_cwd = Some(primary.clone()); live_picker.start_initial_load(); - let event = tokio::time::timeout(std::time::Duration::from_secs(10), bg_rx.recv()) - .await - .expect("app-server page response") - .expect("page event"); - live_picker - .handle_background_event(event) - .await - .expect("ingest app-server page"); + while live_picker.pagination.is_loading() { + let event = tokio::time::timeout(std::time::Duration::from_secs(10), bg_rx.recv()) + .await + .expect("app-server page response") + .expect("page event"); + live_picker + .handle_background_event(event) + .await + .expect("ingest app-server page"); + } assert!( live_picker .filtered_rows @@ -3998,14 +4065,16 @@ mod tests { ); live_picker.filter_mode = SessionFilterMode::Repo; live_picker.start_initial_load(); - let event = tokio::time::timeout(std::time::Duration::from_secs(10), bg_rx.recv()) - .await - .expect("app-server repo page response") - .expect("repo page event"); - live_picker - .handle_background_event(event) - .await - .expect("ingest app-server repo page"); + while live_picker.pagination.is_loading() { + let event = tokio::time::timeout(std::time::Duration::from_secs(10), bg_rx.recv()) + .await + .expect("app-server repo page response") + .expect("repo page event"); + live_picker + .handle_background_event(event) + .await + .expect("ingest app-server repo page"); + } let linked_index = live_picker .filtered_rows .iter() @@ -4025,6 +4094,9 @@ mod tests { live_picker.handle_key(accept).await.expect("fork selection"), Some(SessionSelection::Fork(SessionTarget { thread_id, .. })) if thread_id == linked_id )); + drop(live_picker); + drop(bg_rx); + loader.task.await.unwrap(); let local = crate::latest_session_lookup_params( /*model_provider*/ None, Some(&primary), @@ -4205,6 +4277,15 @@ mod tests { "---- loading fallback ----\n{loading_snapshot}\n---- repaired results ----\n{repaired_snapshot}" ) ); + state.pagination.complete_page( + Some(PageCursor::AppServer("fallback-next".to_string())), + /*num_scanned_files*/ 0, + /*reached_scan_cap*/ false, + ); + assert!(matches!( + state.pagination.next_page(), + Some((_, PageLoadMode::StoreDefault)) + )); } #[tokio::test] @@ -5688,7 +5769,7 @@ session_picker_view = "dense" let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); let loader: PickerLoader = Arc::new(move |request| { - if let PickerLoadRequest::Preview { thread_id } = request { + if let PickerLoadRequest::Preview { thread_id, .. } = request { request_sink.lock().unwrap().push(thread_id); } }); @@ -7408,6 +7489,7 @@ session_picker_view = "dense" .handle_background_event(BackgroundEvent::Page { request_token: first_request.request_token, search_token: first_request.search_token, + mode: first_request.mode, page: Ok(page( vec![make_row("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta")], Some("2025-01-03T00:00:00Z"), @@ -7430,6 +7512,7 @@ session_picker_view = "dense" .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, + mode: second_request.mode, page: Ok(page( vec![make_row( "/tmp/match.jsonl", @@ -7459,6 +7542,7 @@ session_picker_view = "dense" .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, + mode: second_request.mode, page: Ok(page( Vec::new(), /*next_cursor*/ None, @@ -7474,6 +7558,7 @@ session_picker_view = "dense" .handle_background_event(BackgroundEvent::Page { request_token: active_request.request_token, search_token: active_request.search_token, + mode: active_request.mode, page: Ok(page( Vec::new(), /*next_cursor*/ None, diff --git a/codex-rs/tui/src/resume_picker/archive.rs b/codex-rs/tui/src/resume_picker/archive.rs index a3ccf5431277..cdc81addb5d7 100644 --- a/codex-rs/tui/src/resume_picker/archive.rs +++ b/codex-rs/tui/src/resume_picker/archive.rs @@ -13,6 +13,7 @@ use super::SessionPickerLaunchContext; use super::SessionSelection; use super::SessionStatus; use super::SessionTarget; +use crate::backend_id::BackendId; use crate::keymap::KeymapContext; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -70,9 +71,13 @@ impl PickerState { return; } + let backend_id = row.backend_id.unwrap_or(self.backend_id); self.archive_state = ArchiveState::Pending { thread_id }; self.request_frame(); - (self.picker_loader)(PickerLoadRequest::Archive { thread_id }); + (self.picker_loader)(PickerLoadRequest::Archive { + backend_id, + thread_id, + }); } pub(super) fn handle_archive_result( @@ -137,14 +142,17 @@ impl PickerState { } } - pub(super) fn request_unarchive(&mut self, thread_id: ThreadId) { + pub(super) fn request_unarchive(&mut self, thread_id: ThreadId, backend_id: BackendId) { if !matches!(self.archive_state, ArchiveState::Idle) { return; } self.archive_state = ArchiveState::Restoring { thread_id }; self.request_frame(); - (self.picker_loader)(PickerLoadRequest::Unarchive { thread_id }); + (self.picker_loader)(PickerLoadRequest::Unarchive { + backend_id, + thread_id, + }); } pub(super) fn handle_unarchive_result( diff --git a/codex-rs/tui/src/resume_picker/archive_tests.rs b/codex-rs/tui/src/resume_picker/archive_tests.rs index fa07ff7e00f4..98c349c11d95 100644 --- a/codex-rs/tui/src/resume_picker/archive_tests.rs +++ b/codex-rs/tui/src/resume_picker/archive_tests.rs @@ -27,8 +27,8 @@ fn archive_picker_state() -> (PickerState, Arc>>) { let requests = Arc::new(Mutex::new(Vec::new())); let request_sink = Arc::clone(&requests); let picker_loader: PickerLoader = Arc::new(move |request| { - if let PickerLoadRequest::Archive { thread_id } - | PickerLoadRequest::Unarchive { thread_id } = request + if let PickerLoadRequest::Archive { thread_id, .. } + | PickerLoadRequest::Unarchive { thread_id, .. } = request { request_sink .lock() @@ -66,15 +66,21 @@ fn set_selected_session(state: &mut PickerState, thread_id: ThreadId) { #[tokio::test] async fn archive_shortcut_archives_selected_session_once() { - let (mut state, requests) = archive_picker_state(); + let (mut state, _requests) = archive_picker_state(); + let (requests, mut actions) = tokio::sync::mpsc::unbounded_channel(); + state.picker_loader = Arc::new(move |request| requests.send(request).unwrap()); + let backend_id = crate::backend_id::BackendId::new(); let thread_id = ThreadId::new(); set_selected_session(&mut state, thread_id); + state.filtered_rows[0].backend_id = Some(backend_id); let shortcut = KeyEvent::new(KeyCode::Char('\u{0001}'), KeyModifiers::NONE); assert!(state.handle_key(shortcut).await.unwrap().is_none()); assert!(state.handle_key(shortcut).await.unwrap().is_none()); assert_eq!(state.archive_state, ArchiveState::Pending { thread_id }); - assert_eq!(*requests.lock().unwrap(), vec![thread_id]); + assert!( + matches!(actions.recv().await, Some(PickerLoadRequest::Archive { backend_id: owner, thread_id: archived }) if (owner, archived) == (backend_id, thread_id)) + ); assert!( state .handle_key(KeyEvent::from(KeyCode::Enter)) @@ -87,6 +93,17 @@ async fn archive_shortcut_archives_selected_session_once() { assert_eq!(state.archive_state, ArchiveState::Idle); assert!(state.filtered_rows.is_empty()); + + state.status = SessionStatus::Archived; + set_selected_session(&mut state, thread_id); + state.filtered_rows[0].backend_id = Some(backend_id); + state + .handle_key(KeyEvent::from(KeyCode::Enter)) + .await + .unwrap(); + assert!( + matches!(actions.recv().await, Some(PickerLoadRequest::Unarchive { backend_id: owner, thread_id: restored }) if (owner, restored) == (backend_id, thread_id)) + ); } #[test] @@ -287,7 +304,7 @@ fn archived_session_restore_failure_preserves_session_and_allows_retry() { let thread_id = ThreadId::new(); state.status = SessionStatus::Archived; set_selected_session(&mut state, thread_id); - state.request_unarchive(thread_id); + state.request_unarchive(thread_id, state.backend_id); let selection = state.handle_unarchive_result(thread_id, Err(std::io::Error::other("restore denied"))); @@ -300,7 +317,7 @@ fn archived_session_restore_failure_preserves_session_and_allows_retry() { ); assert_eq!(state.filtered_rows.len(), 1); - state.request_unarchive(thread_id); + state.request_unarchive(thread_id, state.backend_id); assert_eq!(*requests.lock().unwrap(), vec![thread_id, thread_id]); } diff --git a/codex-rs/tui/src/resume_picker/catalog.rs b/codex-rs/tui/src/resume_picker/catalog.rs index ccbe25d44231..cfb5939c654c 100644 --- a/codex-rs/tui/src/resume_picker/catalog.rs +++ b/codex-rs/tui/src/resume_picker/catalog.rs @@ -2,9 +2,11 @@ //! Resetting a listing invalidates pending pages without mixing source cursors. use super::PageCursor; +use super::PageLoadMode; use super::PickerPage; use crate::backend_id::BackendId; use std::collections::HashMap; +use std::collections::HashSet; #[derive(Default)] pub(super) struct ThreadCatalog { @@ -15,6 +17,7 @@ pub(super) struct ThreadCatalog { #[derive(Default)] struct Source { cursor: Option, + mode: Option, pending: Option, exhausted: bool, } @@ -23,19 +26,31 @@ pub(super) struct PageRequest { backend: BackendId, token: u64, pub(super) cursor: Option, + pub(super) mode: PageLoadMode, } impl ThreadCatalog { + pub(super) fn has_more(&self, excluded: &HashSet) -> bool { + self.sources + .iter() + .any(|(id, source)| !source.exhausted && !excluded.contains(id)) + } + pub(super) fn reset(&mut self) { self.sources.clear(); } /// A slow or exhausted source does not prevent another source from loading. - pub(super) fn begin_page(&mut self, backend: BackendId) -> Option { + pub(super) fn begin_page( + &mut self, + backend: BackendId, + initial_mode: PageLoadMode, + ) -> Option { let source = self.sources.entry(backend).or_default(); if source.pending.is_some() || source.exhausted { return None; } + let mode = *source.mode.get_or_insert(initial_mode); let token = self.next_request; self.next_request += 1; source.pending = Some(token); @@ -43,6 +58,7 @@ impl ThreadCatalog { backend, token, cursor: source.cursor.clone(), + mode, }) } @@ -57,6 +73,7 @@ impl ThreadCatalog { return None; } source.pending = None; + source.mode = Some(request.mode); Some(page.map(|mut page| { source.cursor = page.next_cursor.clone(); source.exhausted = source.cursor.is_none(); diff --git a/codex-rs/tui/src/resume_picker/catalog_tests.rs b/codex-rs/tui/src/resume_picker/catalog_tests.rs index 68a72d58cef7..8c6543f4101f 100644 --- a/codex-rs/tui/src/resume_picker/catalog_tests.rs +++ b/codex-rs/tui/src/resume_picker/catalog_tests.rs @@ -1,3 +1,4 @@ +#![cfg(test)] //! Source pagination, stale completions, and row-specific source routing. use super::*; @@ -22,10 +23,14 @@ fn listed(thread: ThreadId, cursor: Option<&str>) -> PickerPage { } fn cursor(request: &PageRequest) -> Option<&str> { - request - .cursor - .as_ref() - .map(|PageCursor::AppServer(value)| value.as_str()) + request.cursor.as_ref().map(|cursor| match cursor { + PageCursor::AppServer(value) => value.as_str(), + PageCursor::Catalog => panic!("unexpected catalog cursor"), + }) +} + +fn begin_page(catalog: &mut ThreadCatalog, backend: BackendId) -> Option { + catalog.begin_page(backend, PageLoadMode::StoreDefault) } #[test] @@ -34,13 +39,13 @@ fn sources_progress_independently_and_retry_their_own_cursor() { let slow = BackendId::new(); let failed = BackendId::new(); let healthy = BackendId::new(); - let slow_request = catalog.begin_page(slow).unwrap(); - let first = catalog.begin_page(failed).unwrap(); + let slow_request = begin_page(&mut catalog, slow).unwrap(); + let first = begin_page(&mut catalog, failed).unwrap(); catalog .finish_page(first, Ok(listed(ThreadId::new(), Some("failed-next")))) .unwrap() .unwrap(); - let failing_request = catalog.begin_page(failed).unwrap(); + let failing_request = begin_page(&mut catalog, failed).unwrap(); assert_eq!(cursor(&failing_request), Some("failed-next")); let error = catalog .finish_page(failing_request, Err(std::io::Error::other("unavailable"))) @@ -48,16 +53,16 @@ fn sources_progress_independently_and_retry_their_own_cursor() { .err() .unwrap(); assert_eq!(error.to_string(), "unavailable"); - let healthy_request = catalog.begin_page(healthy).unwrap(); + let healthy_request = begin_page(&mut catalog, healthy).unwrap(); let thread = ThreadId::new(); let result = catalog .finish_page(healthy_request, Ok(listed(thread, /*cursor*/ None))) .unwrap() .unwrap(); assert_eq!(result.rows[0].backend_id, Some(healthy)); - assert!(catalog.begin_page(healthy).is_none()); - assert!(catalog.begin_page(slow).is_none()); - let retry = catalog.begin_page(failed).unwrap(); + assert!(begin_page(&mut catalog, healthy).is_none()); + assert!(begin_page(&mut catalog, slow).is_none()); + let retry = begin_page(&mut catalog, failed).unwrap(); assert_eq!(cursor(&retry), Some("failed-next")); catalog .finish_page(retry, Ok(listed(ThreadId::new(), /*cursor*/ None))) @@ -67,23 +72,23 @@ fn sources_progress_independently_and_retry_their_own_cursor() { .finish_page(slow_request, Ok(listed(ThreadId::new(), /*cursor*/ None))) .unwrap() .unwrap(); - assert!(catalog.begin_page(failed).is_none()); - assert!(catalog.begin_page(slow).is_none()); + assert!(begin_page(&mut catalog, failed).is_none()); + assert!(begin_page(&mut catalog, slow).is_none()); } #[test] fn reset_rejects_old_completions_without_finishing_new_requests() { let mut catalog = ThreadCatalog::default(); let backend = BackendId::new(); - let old = catalog.begin_page(backend).unwrap(); + let old = begin_page(&mut catalog, backend).unwrap(); catalog.reset(); - let current = catalog.begin_page(backend).unwrap(); + let current = begin_page(&mut catalog, backend).unwrap(); assert!( catalog .finish_page(old, Ok(listed(ThreadId::new(), /*cursor*/ None))) .is_none() ); - assert!(catalog.begin_page(backend).is_none()); + assert!(begin_page(&mut catalog, backend).is_none()); let thread = ThreadId::new(); let result = catalog .finish_page(current, Ok(listed(thread, /*cursor*/ None))) @@ -104,7 +109,7 @@ async fn picker_selections_retain_each_rows_source() { let owner = BackendId::new(); let thread = ThreadId::new(); let mut catalog = ThreadCatalog::default(); - let request = catalog.begin_page(owner).unwrap(); + let request = begin_page(&mut catalog, owner).unwrap(); let page = catalog .finish_page(request, Ok(listed(thread, /*cursor*/ None))) .unwrap() @@ -112,7 +117,7 @@ async fn picker_selections_retain_each_rows_source() { picker.ingest_page(page); let other = BackendId::new(); let other_thread = ThreadId::new(); - let other_request = catalog.begin_page(other).unwrap(); + let other_request = begin_page(&mut catalog, other).unwrap(); let other_page = catalog .finish_page(other_request, Ok(listed(other_thread, /*cursor*/ None))) .unwrap() diff --git a/codex-rs/tui/src/resume_picker/page_loading.rs b/codex-rs/tui/src/resume_picker/page_loading.rs index 2c074bb8a6ef..5795c61d068e 100644 --- a/codex-rs/tui/src/resume_picker/page_loading.rs +++ b/codex-rs/tui/src/resume_picker/page_loading.rs @@ -52,7 +52,7 @@ pub(super) enum PageLoadMode { /// Tracks the current request and the cursor policy inherited by its next page. pub(super) struct PaginationState { pub(super) next_cursor: Option, - next_page_mode: PageLoadMode, + pub(super) next_page_mode: PageLoadMode, pub(super) num_scanned_files: usize, pub(super) reached_scan_cap: bool, loading: LoadingState, @@ -134,6 +134,10 @@ impl PaginationState { .map(|cursor| (cursor, self.next_page_mode)) } + pub(super) fn is_pending(&self, request_token: usize) -> bool { + matches!(self.loading, LoadingState::Pending(pending) if pending.request_token == request_token) + } + pub(super) fn is_loading(&self) -> bool { matches!(self.loading, LoadingState::Pending(_)) } diff --git a/codex-rs/tui/src/resume_picker/snapshots/codex_tui__resume_picker__sources__tests__partial_backend_failure.snap b/codex-rs/tui/src/resume_picker/snapshots/codex_tui__resume_picker__sources__tests__partial_backend_failure.snap new file mode 100644 index 000000000000..f9dfa63c89ba --- /dev/null +++ b/codex-rs/tui/src/resume_picker/snapshots/codex_tui__resume_picker__sources__tests__partial_backend_failure.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/resume_picker/sources_tests.rs +assertion_line: 206 +expression: terminal.backend().to_string() +--- +Could not load one backend. Reopen the picker to retry. diff --git a/codex-rs/tui/src/resume_picker/sources.rs b/codex-rs/tui/src/resume_picker/sources.rs new file mode 100644 index 000000000000..5a9efb7901c4 --- /dev/null +++ b/codex-rs/tui/src/resume_picker/sources.rs @@ -0,0 +1,194 @@ +//! Fans listing requests out to independent workers and routes actions to row sources. +//! Closing the picker cancels reads while accepted mutations and source shutdown continue +//! independently; stale listing replies cannot replace current pages. + +use super::*; +use crate::backend_id::BackendId; +use futures::StreamExt; +use futures::stream::SelectAll; +use std::collections::HashSet; + +pub(super) struct Source { + pub(super) backend_id: BackendId, + pub(super) loader: PickerLoader, + pub(super) events: mpsc::UnboundedReceiver, + pub(super) task: tokio::task::JoinHandle<()>, +} + +pub(super) fn spawn( + sources: Vec, + output: mpsc::UnboundedSender, +) -> PickerBackground { + assert!(!sources.is_empty()); + let (input, mut requests) = mpsc::unbounded_channel(); + let mut workers = HashMap::new(); + let mut events = SelectAll::new(); + let mut tasks = Vec::new(); + for Source { + backend_id, + loader, + events: receiver, + task, + } in sources + { + assert!(workers.insert(backend_id, loader).is_none()); + tasks.push(task); + events.push( + UnboundedReceiverStream::new(receiver) + .map(move |event| (backend_id, Some(event))) + .chain(futures::stream::once(async move { (backend_id, None) })) + .boxed(), + ); + } + let task = tokio::spawn(async move { + let mut catalog = catalog::ThreadCatalog::default(); + let mut pending = HashMap::new(); + let mut failed = HashSet::new(); + let mut current: Option = None; + let mut succeeded = false; + let mut error = None; + loop { + tokio::select! { + biased; + _ = output.closed(), if !requests.is_closed() => requests.close(), + request = requests.recv() => { + let Some(request) = request else { break }; + match request { + PickerLoadRequest::Page(request) => { + succeeded = false; + if request.cursor.is_none() { + catalog.reset(); + pending.clear(); + failed.retain(|id| !workers.contains_key(id)); + } + error = workers.is_empty().then(|| std::io::Error::other("No session backends are available.")); + for (id, worker) in &workers { + if failed.contains(id) { continue; } + if let Some(page) = catalog.begin_page(*id, request.mode) { + let mut source_request = request.clone(); + source_request.cursor = page.cursor.clone(); + source_request.mode = page.mode; + pending.insert(*id, page); + worker(PickerLoadRequest::Page(source_request)); + } + } + current = Some(request); + } + request => { + let backend = match &request { + PickerLoadRequest::Preview { backend_id, .. } + | PickerLoadRequest::Transcript { backend_id, .. } + | PickerLoadRequest::Archive { backend_id, .. } + | PickerLoadRequest::Unarchive { backend_id, .. } => *backend_id, + PickerLoadRequest::Page(_) => unreachable!(), + }; + if let Some(worker) = workers.get(&backend) { + worker(request); + } else { + let error = std::io::Error::other("The session's backend is unavailable."); + let event = match request { + PickerLoadRequest::Preview { thread_id, .. } => BackgroundEvent::Preview { thread_id, preview: Err(error) }, + PickerLoadRequest::Transcript { thread_id, .. } => BackgroundEvent::Transcript { thread_id, transcript: Err(error) }, + PickerLoadRequest::Archive { thread_id, .. } => BackgroundEvent::Archive { thread_id, result: Err(error) }, + PickerLoadRequest::Unarchive { thread_id, .. } => BackgroundEvent::Unarchive { thread_id, result: Err(error) }, + PickerLoadRequest::Page(_) => unreachable!(), + }; + let _ = output.send(event); + } + } + } + } + Some((id, event)) = events.next() => { + let event = match event { + Some(event) => event, + None => { + workers.remove(&id); + failed.insert(id); + let Some(request) = current.as_ref().filter(|_| pending.contains_key(&id)) else { continue }; + BackgroundEvent::Page { request_token: request.request_token, search_token: request.search_token, mode: request.mode, + page: Err(std::io::Error::other("The session backend disconnected.")) } + } + }; + match event { + BackgroundEvent::Page { request_token, page, .. } => { + if current.as_ref().is_none_or(|request| request.request_token != request_token) { continue; } + let Some(mut page_request) = pending.remove(&id) else { continue }; + let should_fallback = page_request.mode == PageLoadMode::StateDbOnly + && page_request.cursor.is_none() + && workers.contains_key(&id) + && (page.is_err() + || page.as_ref().is_ok_and(|page| page.rows.is_empty())); + if should_fallback { + let Some(request) = current.as_mut() else { + continue; + }; + request.mode = PageLoadMode::StoreDefault; + let mut fallback = request.clone(); + fallback.cursor = page_request.cursor.clone(); + page_request.mode = PageLoadMode::StoreDefault; + pending.insert(id, page_request); + workers[&id](PickerLoadRequest::Page(fallback)); + continue; + } + match page { + Ok(page) => { + let Some(Ok(mut page)) = catalog.finish_page(page_request, Ok(page)) else { continue }; + succeeded = true; + // Only the catalog owns source cursors. The UI receives a + // continuation marker once this round's workers have replied. + page.next_cursor = None; + let _ = output.send(BackgroundEvent::PartialPage { request_token, page }); + } + Err(err) => { + let Some(Err(err)) = catalog.finish_page(page_request, Err(err)) else { continue }; + failed.insert(id); + error = Some(err); + let _ = output.send(BackgroundEvent::SourceError { request_token }); + } + } + } + event => { let _ = output.send(event); } + } + } + } + if pending.is_empty() + && let Some(request) = current.take() + { + let page = if !succeeded && let Some(error) = error.take() { + Err(error) + } else { + Ok(PickerPage { + rows: Vec::new(), + history_modes: HashMap::new(), + next_cursor: catalog.has_more(&failed).then_some(PageCursor::Catalog), + num_scanned_files: 0, + reached_scan_cap: false, + }) + }; + let _ = output.send(BackgroundEvent::Page { + request_token: request.request_token, + search_token: request.search_token, + mode: request.mode, + page, + }); + } + } + drop(workers); + drop(events); + for task in tasks { + if let Err(err) = task.await { + tracing::warn!(%err, "Picker source task stopped unexpectedly"); + } + } + }); + PickerBackground { + loader: Arc::new(move |request| { + let _ = input.send(request); + }), + task, + } +} + +#[cfg(test)] +#[path = "sources_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/resume_picker/sources_tests.rs b/codex-rs/tui/src/resume_picker/sources_tests.rs new file mode 100644 index 000000000000..c37b87fd44db --- /dev/null +++ b/codex-rs/tui/src/resume_picker/sources_tests.rs @@ -0,0 +1,367 @@ +#![cfg(test)] +//! Exercises independent worker replies, stale pages, and action ownership. + +use super::*; +use crate::resume_picker::tests::make_row; +use crate::resume_picker::tests::page; +use pretty_assertions::assert_eq; +fn source() -> ( + Source, + mpsc::UnboundedReceiver, + mpsc::UnboundedSender, +) { + let (requests, receiver) = mpsc::unbounded_channel(); + let (events, responses) = mpsc::unbounded_channel(); + ( + Source { + backend_id: BackendId::new(), + loader: Arc::new(move |request| { + let _ = requests.send(request); + }), + events: responses, + task: tokio::spawn(async {}), + }, + receiver, + events, + ) +} +fn request(token: usize, cursor: Option) -> PickerLoadRequest { + PickerLoadRequest::Page(PageLoadRequest { + request_token: token, + cursor, + search_token: None, + mode: PageLoadMode::StoreDefault, + cwd_filter: None, + filter_mode: SessionFilterMode::All, + status: SessionStatus::Active, + provider_filter: ProviderFilter::Any, + sort_key: ThreadSortKey::UpdatedAt, + }) +} +async fn next(receiver: &mut mpsc::UnboundedReceiver) -> BackgroundEvent { + tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv()) + .await + .unwrap() + .unwrap() +} +fn reply(token: usize, thread: ThreadId, cursor: Option<&str>) -> BackgroundEvent { + let mut row = make_row("/tmp/worker.jsonl", "2025-01-01T00:00:00Z", "conversation"); + row.thread_id = Some(thread); + BackgroundEvent::Page { + request_token: token, + search_token: None, + mode: PageLoadMode::StoreDefault, + page: Ok(page( + vec![row], + cursor, + /*num_scanned_files*/ 1, + /*reached_scan_cap*/ false, + )), + } +} +#[tokio::test] +async fn sources_stream_rows_and_route_actions_to_each_source() { + let (first, mut first_requests, first_events) = source(); + let first_id = first.backend_id; + let (second, mut second_requests, second_events) = source(); + let second_id = second.backend_id; + let (third, mut third_requests, third_events) = source(); + let (output, mut events) = mpsc::unbounded_channel(); + let PickerBackground { loader, task } = spawn(vec![first, second, third], output); + let PickerLoadRequest::Page(mut initial_request) = + request(/*token*/ 1, /*cursor*/ None) + else { + unreachable!() + }; + initial_request.mode = PageLoadMode::StateDbOnly; + loader(PickerLoadRequest::Page(initial_request)); + for requests in [ + &mut first_requests, + &mut second_requests, + &mut third_requests, + ] { + assert!(matches!( + requests.recv().await, + Some(PickerLoadRequest::Page(_)) + )); + } + let thread = ThreadId::new(); + second_events + .send(BackgroundEvent::Page { + request_token: 1, + search_token: None, + mode: PageLoadMode::StateDbOnly, + page: Err(std::io::Error::other("indexed listing failed")), + }) + .unwrap(); + assert!(events.try_recv().is_err()); + let Some(PickerLoadRequest::Page(fallback_request)) = second_requests.recv().await else { + panic!("expected source-specific fallback") + }; + assert_eq!(fallback_request.mode, PageLoadMode::StoreDefault); + second_events + .send(reply(/*token*/ 1, thread, /*cursor*/ None)) + .unwrap(); + let BackgroundEvent::PartialPage { page, .. } = next(&mut events).await else { + panic!("expected responsive source page") + }; + assert_eq!(page.rows[0].backend_id, Some(second_id)); + first_events + .send(reply(/*token*/ 1, thread, Some("first-next"))) + .unwrap(); + let BackgroundEvent::PartialPage { page, .. } = next(&mut events).await else { + panic!("expected other source page") + }; + assert_eq!(page.rows[0].backend_id, Some(first_id)); + loader(PickerLoadRequest::Archive { + backend_id: first_id, + thread_id: thread, + }); + assert!( + matches!(first_requests.recv().await, Some(PickerLoadRequest::Archive { backend_id, thread_id }) if (backend_id, thread_id) == (first_id, thread)) + ); + let (_cancel, cancellation) = oneshot::channel(); + loader(PickerLoadRequest::Transcript { + backend_id: second_id, + thread_id: thread, + cancellation, + }); + assert!( + matches!(second_requests.recv().await, Some(PickerLoadRequest::Transcript { backend_id, thread_id, .. }) if (backend_id, thread_id) == (second_id, thread)) + ); + drop(third_events); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::SourceError { .. } + )); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::Page { + mode: PageLoadMode::StoreDefault, + page: Ok(_), + .. + } + )); + loader(request(/*token*/ 2, Some(PageCursor::Catalog))); + let Some(PickerLoadRequest::Page(next_page)) = first_requests.recv().await else { + panic!("expected next page") + }; + assert_eq!(next_page.mode, PageLoadMode::StateDbOnly); + assert!( + matches!(next_page.cursor, Some(PageCursor::AppServer(cursor)) if cursor == "first-next") + ); + assert!(second_requests.try_recv().is_err()); + first_events + .send(reply( + /*token*/ 2, + ThreadId::new(), + /*cursor*/ Some("last-page"), + )) + .unwrap(); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::PartialPage { .. } + )); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::Page { page: Ok(_), .. } + )); + loader(request(/*token*/ 3, Some(PageCursor::Catalog))); + let Some(PickerLoadRequest::Page(last_page)) = first_requests.recv().await else { + panic!("expected last page") + }; + assert!( + matches!(last_page.cursor, Some(PageCursor::AppServer(cursor)) if cursor == "last-page") + ); + first_events + .send(BackgroundEvent::Page { + request_token: 3, + search_token: None, + mode: PageLoadMode::StateDbOnly, + page: Err(std::io::Error::other("last page failed")), + }) + .unwrap(); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::SourceError { request_token: 3 } + )); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::Page { + request_token: 3, + page: Err(_), + .. + } + )); + drop(loader); + task.await.unwrap(); + assert!(first_requests.recv().await.is_none()); + assert!(second_requests.recv().await.is_none()); +} +#[tokio::test] +async fn reset_rejects_old_replies_and_missing_sources_never_fall_back() { + let (source, mut requests, replies) = source(); + let owner = source.backend_id; + let (output, mut events) = mpsc::unbounded_channel(); + let PickerBackground { loader, task } = spawn(vec![source], output); + loader(request(/*token*/ 1, /*cursor*/ None)); + requests.recv().await.unwrap(); + loader(request(/*token*/ 2, /*cursor*/ None)); + let stale = ThreadId::new(); + let current = ThreadId::new(); + replies + .send(reply(/*token*/ 1, stale, /*cursor*/ None)) + .unwrap(); + replies + .send(reply(/*token*/ 2, current, /*cursor*/ None)) + .unwrap(); + let BackgroundEvent::PartialPage { + request_token, + page, + } = next(&mut events).await + else { + panic!("expected current page") + }; + assert_eq!((request_token, page.rows[0].backend_id), (2, Some(owner))); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::Page { + request_token: 2, + .. + } + )); + loader(PickerLoadRequest::Archive { + backend_id: BackendId::new(), + thread_id: stale, + }); + assert!( + matches!(next(&mut events).await, BackgroundEvent::Archive { thread_id, result: Err(_), .. } if thread_id == stale) + ); + requests.recv().await.unwrap(); + loader(PickerLoadRequest::Archive { + backend_id: owner, + thread_id: current, + }); + drop(events); + assert!( + matches!(requests.recv().await, Some(PickerLoadRequest::Archive { thread_id, .. }) if thread_id == current) + ); + task.await.unwrap(); + assert!(requests.recv().await.is_none()); +} +#[tokio::test] +async fn reset_does_not_restore_a_disconnected_source() { + let (preferred, mut preferred_requests, preferred_events) = source(); + let (fallback, mut fallback_requests, fallback_events) = source(); + let owner = fallback.backend_id; + let (output, mut events) = mpsc::unbounded_channel(); + let PickerBackground { loader, task } = spawn(vec![preferred, fallback], output); + loader(request(/*token*/ 1, /*cursor*/ None)); + preferred_requests.recv().await.unwrap(); + fallback_requests.recv().await.unwrap(); + drop(preferred_events); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::SourceError { request_token: 1 } + )); + fallback_events + .send(reply( + /*token*/ 1, + ThreadId::new(), + /*cursor*/ None, + )) + .unwrap(); + assert!(matches!( + next(&mut events).await, + BackgroundEvent::PartialPage { .. } + )); + next(&mut events).await; + loader(request(/*token*/ 2, /*cursor*/ None)); + let thread = ThreadId::new(); + fallback_requests.recv().await.unwrap(); + fallback_events + .send(reply(/*token*/ 2, thread, /*cursor*/ None)) + .unwrap(); + let BackgroundEvent::PartialPage { page, .. } = next(&mut events).await else { + panic!("expected fallback page after reset") + }; + assert_eq!(page.rows[0].backend_id, Some(owner)); + drop(loader); + task.await.unwrap(); +} +#[tokio::test] +async fn detached_picker_cleanup_drains_accepted_mutations() { + let backend_id = BackendId::new(); + let thread_id = ThreadId::new(); + let (requests, mut request_rx) = mpsc::unbounded_channel(); + let (source_events, source_responses) = mpsc::unbounded_channel(); + let (mutation_finished, mutation_completion) = oneshot::channel(); + let source_task = tokio::spawn(async move { + assert!(matches!( + request_rx.recv().await, + Some(PickerLoadRequest::Page(_)) + )); + source_events.closed().await; + assert!( + matches!(request_rx.recv().await, Some(PickerLoadRequest::Archive { backend_id: source, thread_id: thread }) if (source, thread) == (backend_id, thread_id)) + ); + mutation_finished.send(()).unwrap(); + assert!(request_rx.recv().await.is_none()); + }); + let source = Source { + backend_id, + loader: Arc::new(move |request| { + let _ = requests.send(request); + }), + events: source_responses, + task: source_task, + }; + let (output, events) = mpsc::unbounded_channel(); + let PickerBackground { loader, task } = spawn(vec![source], output); + loader(request(/*token*/ 1, /*cursor*/ None)); + loader(PickerLoadRequest::Archive { + backend_id, + thread_id, + }); + drop(events); + drop(task); + mutation_completion.await.unwrap(); +} +#[tokio::test] +async fn partial_failure_keeps_healthy_rows_visible() { + let (mut picker, _) = crate::resume_picker::tests::local_db_first_state(); + picker.pagination.start_load( + /*request_token*/ 42, + /*search_token*/ None, + PageLoadMode::StoreDefault, + ); + let thread = ThreadId::new(); + let BackgroundEvent::Page { page: Ok(page), .. } = + reply(/*token*/ 42, thread, /*cursor*/ None) + else { + unreachable!() + }; + picker + .handle_background_event(BackgroundEvent::PartialPage { + request_token: 42, + page, + }) + .await + .unwrap(); + picker + .handle_background_event(BackgroundEvent::SourceError { request_token: 42 }) + .await + .unwrap(); + assert_eq!(picker.all_rows.len(), 1); + assert!(picker.pagination.is_loading()); + let backend = crate::test_backend::VT100Backend::new(/*width*/ 80, /*height*/ 1); + let mut terminal = crate::custom_terminal::Terminal::with_options(backend).unwrap(); + terminal.set_viewport_area(Rect::new(0, 0, 80, 1)); + { + let mut frame = terminal.get_frame(); + let line = search_line(&picker, frame.area().width); + frame.render_widget_ref(&line, frame.area()); + } + terminal.flush().unwrap(); + insta::assert_snapshot!("partial_backend_failure", terminal.backend().to_string()); +} diff --git a/codex-rs/tui/src/resume_picker_pagination_error_tests.rs b/codex-rs/tui/src/resume_picker_pagination_error_tests.rs index 949d0c9e6670..85848e16532e 100644 --- a/codex-rs/tui/src/resume_picker_pagination_error_tests.rs +++ b/codex-rs/tui/src/resume_picker_pagination_error_tests.rs @@ -33,6 +33,7 @@ async fn later_page_error_preserves_loaded_sessions() { .handle_background_event(BackgroundEvent::Page { request_token: request.request_token, search_token: request.search_token, + mode: request.mode, page: Ok(PickerPage { rows: vec![Row { backend_id: None, @@ -67,6 +68,7 @@ async fn later_page_error_preserves_loaded_sessions() { .handle_background_event(BackgroundEvent::Page { request_token: request.request_token, search_token: request.search_token, + mode: request.mode, page: Err(std::io::Error::other( "failed to list threads from state database", )), From 13b1318f2e59452604d07a2e23dc9d821187722f Mon Sep 17 00:00:00 2001 From: thorvald-oai Date: Sun, 25 Oct 2026 02:00:13 +0000 Subject: [PATCH 041/287] Support negotiated WebSocket compression in the remote app-server client (#57218) ## What changed Advertise `zstd-stream-v1` compression for WebSocket and Unix socket connections and use the negotiated codec for JSON-RPC messages. Retain compression history for the connection and keep plain-text messaging when compression is not negotiated. Fragment compressed writes to respect the server's advertised frame limit, reject text messages in compressed sessions, and stop using the connection after a failed write to avoid mismatched compression history. ## Testing Add an integration test covering compression negotiation, fragmentation, retained codec history, request/response handling, and disconnection on an uncompressed text message. GitOrigin-RevId: 510073444ea49376a116c05214bcb79bd0bc3b50 --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-client/Cargo.toml | 1 + codex-rs/app-server-client/src/lib.rs | 1 + codex-rs/app-server-client/src/remote.rs | 65 +++++----- .../app-server-client/src/remote_websocket.rs | 117 ++++++++++++++++++ .../tests/remote_compression.rs | 115 +++++++++++++++++ 6 files changed, 270 insertions(+), 30 deletions(-) create mode 100644 codex-rs/app-server-client/src/remote_websocket.rs create mode 100644 codex-rs/app-server-client/tests/remote_compression.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c28b3752ea2e..962dfa6219bc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2179,6 +2179,7 @@ version = "0.0.0" dependencies = [ "codex-app-server", "codex-app-server-protocol", + "codex-app-server-transport", "codex-arg0", "codex-config", "codex-core", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index daa6ef661f00..272173b83a7f 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-app-server-transport = { workspace = true } codex-arg0 = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 4d42819eb4b9..e7df794ea809 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -23,6 +23,7 @@ mod cloud; mod path; mod remote; +mod remote_websocket; pub use cloud::CloudClient; pub use cloud::CloudCommand; pub use cloud::CloudOperation; diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index c5d9b0321f05..ad8d28bceced 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -22,6 +22,8 @@ use crate::RequestResult; use crate::SHUTDOWN_TIMEOUT; use crate::TypedRequestError; use crate::decode_typed_response; +use crate::remote_websocket::RemoteWebSocket; +use crate::remote_websocket::WebSocketHandshake; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; @@ -37,11 +39,11 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_transport::WEBSOCKET_COMPRESSION_HEADER; +use codex_app_server_transport::ZSTD_STREAM_PROTOCOL; use codex_uds::UnixStream; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; -use futures::SinkExt; -use futures::StreamExt; use serde::de::DeserializeOwned; use tokio::io::AsyncRead; use tokio::io::AsyncWrite; @@ -50,12 +52,12 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_tungstenite::MaybeTlsStream; -use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::client_async_with_config; use tokio_tungstenite::connect_async_with_config; use tokio_tungstenite::tungstenite::Error as TungsteniteError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::handshake::client::Request; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; @@ -239,13 +241,14 @@ impl RemoteAppServerClient { async fn connect_with_stream( channel_capacity: usize, endpoint: String, - stream: WebSocketStream, + stream: WebSocketHandshake, initialize_params: InitializeParams, ) -> IoResult where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let mut stream = stream; + let mut stream = + RemoteWebSocket::from_handshake(stream, REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)?; let (pending_events, metadata) = initialize_remote_connection( &mut stream, &endpoint, @@ -264,7 +267,7 @@ impl RemoteAppServerClient { tokio::select! { command = command_rx.recv() => { let Some(command) = command else { - let _ = stream.close(None).await; + let _ = stream.close().await; break; }; match command { @@ -346,7 +349,7 @@ impl RemoteAppServerClient { let _ = response_tx.send(result); } RemoteClientCommand::Shutdown { response_tx } => { - let close_result = stream.close(None).await.or_else(|err| { + let close_result = stream.close().await.or_else(|err| { if websocket_close_error_is_already_closed(&err) { Ok(()) } else { @@ -719,7 +722,7 @@ impl RemoteAppServerRequestHandle { async fn connect_websocket_endpoint( websocket_url: String, auth_token: Option, -) -> IoResult<(String, WebSocketStream>)> { +) -> IoResult<(String, WebSocketHandshake>)> { let url = Url::parse(&websocket_url).map_err(|err| { IoError::new( ErrorKind::InvalidInput, @@ -735,7 +738,7 @@ async fn connect_websocket_endpoint( )); } - let mut request = url.as_str().into_client_request().map_err(|err| { + let mut request = websocket_request(url.as_str()).map_err(|err| { IoError::new( ErrorKind::InvalidInput, format!("invalid websocket URL `{websocket_url}`: {err}"), @@ -769,7 +772,6 @@ async fn connect_websocket_endpoint( format!("timed out connecting to remote app server at `{websocket_url}`"), ) })? - .map(|(stream, _response)| stream) .map_err(|err| { IoError::other(format!( "failed to connect to remote app server at `{websocket_url}`: {err}" @@ -782,16 +784,14 @@ async fn connect_websocket_endpoint( async fn connect_unix_socket_endpoint( socket_path: AbsolutePathBuf, peer_policy: SocketPeerPolicy, -) -> IoResult<(String, WebSocketStream)> { +) -> IoResult<(String, WebSocketHandshake)> { let endpoint = format!("unix://{}", socket_path.display()); - let request = UDS_WEBSOCKET_HANDSHAKE_URL - .into_client_request() - .map_err(|err| { - IoError::new( - ErrorKind::InvalidInput, - format!("invalid UDS websocket handshake URL: {err}"), - ) - })?; + let request = websocket_request(UDS_WEBSOCKET_HANDSHAKE_URL).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid UDS websocket handshake URL: {err}"), + ) + })?; let stream = timeout(CONNECT_TIMEOUT, UnixStream::connect(socket_path.as_path())) .await .map_err(|_| { @@ -822,7 +822,6 @@ async fn connect_unix_socket_endpoint( format!("timed out upgrading remote app server at `{endpoint}`"), ) })? - .map(|(stream, _response)| stream) .map_err(|err| { IoError::other(format!( "failed to upgrade remote app server at `{endpoint}`: {err}" @@ -832,6 +831,15 @@ async fn connect_unix_socket_endpoint( Ok((endpoint, stream)) } +fn websocket_request(url: &str) -> Result { + let mut request = url.into_client_request()?; + request.headers_mut().insert( + WEBSOCKET_COMPRESSION_HEADER, + HeaderValue::from_static(ZSTD_STREAM_PROTOCOL), + ); + Ok(request) +} + fn remote_websocket_config() -> WebSocketConfig { WebSocketConfig::default() .max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) @@ -839,7 +847,7 @@ fn remote_websocket_config() -> WebSocketConfig { } async fn initialize_remote_connection( - stream: &mut WebSocketStream, + stream: &mut RemoteWebSocket, endpoint: &str, params: InitializeParams, initialize_timeout: Duration, @@ -1030,7 +1038,7 @@ fn jsonrpc_notification_from_client_notification( } async fn write_jsonrpc_message( - stream: &mut WebSocketStream, + stream: &mut RemoteWebSocket, message: JSONRPCMessage, endpoint: &str, ) -> IoResult<()> @@ -1038,14 +1046,11 @@ where S: AsyncRead + AsyncWrite + Unpin, { let payload = serde_json::to_string(&message).map_err(IoError::other)?; - stream - .send(Message::Text(payload.into())) - .await - .map_err(|err| { - IoError::other(format!( - "failed to write websocket message to `{endpoint}`: {err}" - )) - }) + stream.send_text(payload).await.map_err(|err| { + IoError::other(format!( + "failed to write websocket message to `{endpoint}`: {err}" + )) + }) } fn websocket_close_error_is_already_closed(err: &TungsteniteError) -> bool { diff --git a/codex-rs/app-server-client/src/remote_websocket.rs b/codex-rs/app-server-client/src/remote_websocket.rs new file mode 100644 index 000000000000..5caf0db845f4 --- /dev/null +++ b/codex-rs/app-server-client/src/remote_websocket.rs @@ -0,0 +1,117 @@ +//! Negotiated WebSocket compression. Codec history lasts for the connection. + +use std::io; + +use codex_app_server_transport::WEBSOCKET_COMPRESSION_HEADER; +use codex_app_server_transport::WebSocketCompression; +use codex_app_server_transport::ZstdDecoder; +use codex_app_server_transport::ZstdEncoder; +use futures::SinkExt; +use futures::StreamExt; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Bytes; +use tokio_tungstenite::tungstenite::Error; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::client::Response; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::protocol::frame::Frame; +use tokio_tungstenite::tungstenite::protocol::frame::coding::Data; +use tokio_tungstenite::tungstenite::protocol::frame::coding::OpCode; + +// Match the listener's default incoming frame limit when no smaller cap is advertised. +const DEFAULT_MAX_FRAME_BYTES: usize = 16 << 20; +const MAX_UNFRAGMENTED_MESSAGE_BYTES_HEADER: &str = + "x-codex-websocket-max-unfragmented-message-bytes"; + +pub(crate) type WebSocketHandshake = (WebSocketStream, Response); + +pub(crate) struct RemoteWebSocket { + stream: WebSocketStream, + codec: Option<(ZstdEncoder, ZstdDecoder)>, + max_frame_bytes: usize, + write_failed: bool, +} + +impl RemoteWebSocket { + pub(crate) fn from_handshake( + (stream, response): WebSocketHandshake, + max_message_size: usize, + ) -> io::Result { + let headers = response.headers(); + let header = headers + .get(WEBSOCKET_COMPRESSION_HEADER) + .map(HeaderValue::to_str) + .transpose() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let codec = match WebSocketCompression::from_response_header(header)? { + WebSocketCompression::Plain => None, + WebSocketCompression::Zstd => { + Some((ZstdEncoder::new()?, ZstdDecoder::new(max_message_size)?)) + } + }; + let max_frame_bytes = headers + .get(MAX_UNFRAGMENTED_MESSAGE_BYTES_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_MAX_FRAME_BYTES) + .min(DEFAULT_MAX_FRAME_BYTES); + Ok(Self { + stream, + codec, + max_frame_bytes, + write_failed: false, + }) + } + + pub(crate) async fn next(&mut self) -> Option> { + if self.write_failed { + return Some(Err(Error::Io(io::ErrorKind::BrokenPipe.into()))); + } + let message = self.stream.next().await?; + Some(match (&mut self.codec, message) { + (Some((_, decoder)), Ok(Message::Binary(bytes))) => decoder + .decode(&bytes) + .map(|text| Message::Text(text.into())) + .map_err(Error::Io), + (Some(_), Ok(Message::Text(_))) => Err(Error::Io(io::Error::new( + io::ErrorKind::InvalidData, + "expected compressed binary app-server message", + ))), + (_, message) => message, + }) + } + + pub(crate) async fn send_text(&mut self, text: String) -> Result<(), Error> { + if self.write_failed { + return Err(Error::Io(io::ErrorKind::BrokenPipe.into())); + } + let result = async { + let Some((encoder, _)) = &mut self.codec else { + return self.stream.send(Message::Text(text.into())).await; + }; + let mut bytes = Bytes::from(encoder.encode(&text).map_err(Error::Io)?); + if bytes.len() <= self.max_frame_bytes { + return self.stream.send(Message::Binary(bytes)).await; + } + let mut opcode = OpCode::Data(Data::Binary); + while !bytes.is_empty() { + let chunk = bytes.split_to(bytes.len().min(self.max_frame_bytes)); + let frame = Frame::message(chunk, opcode, bytes.is_empty()); + self.stream.feed(Message::Frame(frame)).await?; + opcode = OpCode::Data(Data::Continue); + } + self.stream.flush().await + } + .await; + // A failed write may leave the compression history ahead of the receiver. + self.write_failed = result.is_err(); + result + } + + pub(crate) async fn close(&mut self) -> Result<(), Error> { + self.stream.close(/*msg*/ None).await + } +} diff --git a/codex-rs/app-server-client/tests/remote_compression.rs b/codex-rs/app-server-client/tests/remote_compression.rs new file mode 100644 index 000000000000..cbab159c1d7d --- /dev/null +++ b/codex-rs/app-server-client/tests/remote_compression.rs @@ -0,0 +1,115 @@ +//! Exercise a negotiated compressed session through the remote client API. + +use std::error::Error; + +use codex_app_server_client::AppServerEvent; +use codex_app_server_client::RemoteAppServerClient; +use codex_app_server_client::RemoteAppServerConnectArgs; +use codex_app_server_client::RemoteAppServerEndpoint; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_transport::WEBSOCKET_COMPRESSION_HEADER; +use codex_app_server_transport::ZSTD_STREAM_PROTOCOL; +use codex_app_server_transport::ZstdDecoder; +use codex_app_server_transport::ZstdEncoder; +use codex_uds::UnixListener; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio_tungstenite::accept_hdr_async_with_config; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::server::Request; +use tokio_tungstenite::tungstenite::handshake::server::Response; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; + +#[tokio::test] +async fn compressed_session_negotiates_fragments_and_retains_history() -> Result<(), Box> +{ + const FRAME_LIMIT: usize = 16; + let dir = TempDir::new()?; + let socket_path = AbsolutePathBuf::from_absolute_path(dir.path().join("codex.sock"))?; + let mut listener = UnixListener::bind(socket_path.as_path()).await?; + let server = async { + let stream = listener.accept().await?; + let mut websocket = accept_hdr_async_with_config( + stream, + |request: &Request, mut response: Response| { + assert_eq!( + request.headers()[WEBSOCKET_COMPRESSION_HEADER], + ZSTD_STREAM_PROTOCOL + ); + response.headers_mut().insert( + WEBSOCKET_COMPRESSION_HEADER, + HeaderValue::from_static(ZSTD_STREAM_PROTOCOL), + ); + response.headers_mut().insert( + "x-codex-websocket-max-unfragmented-message-bytes", + HeaderValue::from(FRAME_LIMIT), + ); + Ok(response) + }, + Some(WebSocketConfig::default().max_frame_size(Some(FRAME_LIMIT))), + ) + .await?; + let mut decoder = ZstdDecoder::new(/*max_message_size*/ 1024)?; + let mut encoder = ZstdEncoder::new()?; + for expected_method in ["initialize", "initialized", "echo"] { + let Message::Binary(bytes) = websocket.next().await.ok_or("connection closed")?? else { + return Err("expected compressed message".into()); + }; + if expected_method == "initialize" { + assert!( + bytes.len() > FRAME_LIMIT, + "initialize must require fragmentation" + ); + } + let request: Value = serde_json::from_str(&decoder.decode(&bytes)?)?; + assert_eq!(request["method"], expected_method); + if expected_method != "initialized" { + let response = json!({"id": request["id"], "result": {"ok": true}}).to_string(); + websocket + .send(Message::Binary(encoder.encode(&response)?.into())) + .await?; + } + } + websocket.send(Message::Text("{}".into())).await?; + Ok::<(), Box>(()) + }; + let client = async { + let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, + client_name: "compression-test".to_string(), + client_version: "0.0.0".to_string(), + experimental_api: false, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, + }) + .await?; + assert_eq!( + client + .request_handle() + .request_json_rpc(JSONRPCRequest { + id: RequestId::Integer(1), + method: "echo".to_string(), + params: None, + trace: None, + }) + .await?, + Ok(json!({"ok": true})) + ); + let Some(AppServerEvent::Disconnected { message }) = client.next_event().await else { + return Err("expected disconnect after uncompressed message".into()); + }; + assert!(message.contains("expected compressed binary")); + client.shutdown().await?; + Ok::<(), Box>(()) + }; + tokio::try_join!(server, client)?; + Ok(()) +} From 20f5dde71411ceab586d5898ba52cf68d56f9a02 Mon Sep 17 00:00:00 2001 From: thorvald-oai Date: Sun, 25 Oct 2026 04:00:09 +0000 Subject: [PATCH 042/287] Log app-server WebSocket compression statistics (#57220) ## What changed Track cumulative compressed and uncompressed payload bytes, message counts, and percentage savings for each connection and direction. Use paired Zstd codecs for listener and native client connections, sharing a connection ID between transmit and receive statistics. Log totals as traffic is processed at 60-second intervals and when codecs are dropped. Transmit totals include encoded payloads even if the subsequent socket write fails; receive totals count only successfully decoded messages. ## Testing Extend compression tests to verify shared connection IDs, matching transmit and receive totals, savings calculations, and exclusion of oversized messages rejected during decoding. GitOrigin-RevId: 32602caa85864de7a4e0d28907311af6f67d8db2 --- .../app-server-client/src/remote_websocket.rs | 9 +- codex-rs/app-server-transport/src/lib.rs | 2 + .../src/transport/websocket.rs | 23 ++-- .../src/websocket_compression.rs | 114 +++++++++++++++++- .../src/websocket_compression_tests.rs | 35 +++++- 5 files changed, 164 insertions(+), 19 deletions(-) diff --git a/codex-rs/app-server-client/src/remote_websocket.rs b/codex-rs/app-server-client/src/remote_websocket.rs index 5caf0db845f4..44fac9537732 100644 --- a/codex-rs/app-server-client/src/remote_websocket.rs +++ b/codex-rs/app-server-client/src/remote_websocket.rs @@ -2,10 +2,12 @@ use std::io; +use codex_app_server_transport::CompressionConnection; use codex_app_server_transport::WEBSOCKET_COMPRESSION_HEADER; use codex_app_server_transport::WebSocketCompression; use codex_app_server_transport::ZstdDecoder; use codex_app_server_transport::ZstdEncoder; +use codex_app_server_transport::zstd_codec_pair; use futures::SinkExt; use futures::StreamExt; use tokio::io::AsyncRead; @@ -47,9 +49,10 @@ impl RemoteWebSocket { .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; let codec = match WebSocketCompression::from_response_header(header)? { WebSocketCompression::Plain => None, - WebSocketCompression::Zstd => { - Some((ZstdEncoder::new()?, ZstdDecoder::new(max_message_size)?)) - } + WebSocketCompression::Zstd => Some(zstd_codec_pair( + max_message_size, + CompressionConnection::NativeClient, + )?), }; let max_frame_bytes = headers .get(MAX_UNFRAGMENTED_MESSAGE_BYTES_HEADER) diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index 895f65a0fd71..d635273a4024 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -45,8 +45,10 @@ pub use transport::start_stdio_connection; pub use transport::start_websocket_acceptor; pub use transport::take_remote_control_disabled_env; pub use transport::verified_local_daemon_socket; +pub use websocket_compression::CompressionConnection; pub use websocket_compression::WEBSOCKET_COMPRESSION_HEADER; pub use websocket_compression::WebSocketCompression; pub use websocket_compression::ZSTD_STREAM_PROTOCOL; pub use websocket_compression::ZstdDecoder; pub use websocket_compression::ZstdEncoder; +pub use websocket_compression::zstd_codec_pair; diff --git a/codex-rs/app-server-transport/src/transport/websocket.rs b/codex-rs/app-server-transport/src/transport/websocket.rs index f02aed975a56..4c15beb72dee 100644 --- a/codex-rs/app-server-transport/src/transport/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/websocket.rs @@ -11,6 +11,7 @@ use crate::websocket_compression::WebSocketCompression; use crate::websocket_compression::ZSTD_STREAM_PROTOCOL; use crate::websocket_compression::ZstdDecoder; use crate::websocket_compression::ZstdEncoder; +use crate::websocket_compression::zstd_codec_pair; use axum::Router; use axum::body::Body; use axum::body::Bytes; @@ -211,20 +212,22 @@ pub(crate) async fn run_websocket_connection( SinkError: Send + 'static, StreamError: std::fmt::Display + Send + 'static, { + let connection_id = next_connection_id(); let (encoder, decoder) = match compression { WebSocketCompression::Plain => (None, None), - WebSocketCompression::Zstd => match ( - ZstdEncoder::new(), - ZstdDecoder::new(MAX_DECOMPRESSED_MESSAGE_SIZE), - ) { - (Ok(encoder), Ok(decoder)) => (Some(encoder), Some(decoder)), - _ => { - warn!("failed to initialize websocket compression"); - return; + WebSocketCompression::Zstd => { + match zstd_codec_pair( + MAX_DECOMPRESSED_MESSAGE_SIZE, + crate::CompressionConnection::Listener(connection_id), + ) { + Ok((encoder, decoder)) => (Some(encoder), Some(decoder)), + Err(_) => { + warn!("failed to initialize websocket compression"); + return; + } } - }, + } }; - let connection_id = next_connection_id(); let (writer_tx, writer_rx) = mpsc::channel::(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); diff --git a/codex-rs/app-server-transport/src/websocket_compression.rs b/codex-rs/app-server-transport/src/websocket_compression.rs index 17ac36e0b011..2aeface92e49 100644 --- a/codex-rs/app-server-transport/src/websocket_compression.rs +++ b/codex-rs/app-server-transport/src/websocket_compression.rs @@ -6,8 +6,13 @@ //! Each binary message contains one UTF-8 JSON message flushed with `ZSTD_e_flush`. //! Keep separate streams per direction and start fresh on reconnect. Never finish //! the zstd frame between messages. +//! Paired codecs report cumulative payload sizes per connection and direction. use std::io; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; use zstd::stream::raw::CParameter; use zstd::stream::raw::DParameter; use zstd::stream::raw::Decoder; @@ -15,11 +20,15 @@ use zstd::stream::raw::Encoder; use zstd::stream::raw::Operation; use zstd::stream::raw::OutBuffer; +use crate::ConnectionId; + pub const WEBSOCKET_COMPRESSION_HEADER: &str = "x-codex-app-server-compression"; pub const ZSTD_STREAM_PROTOCOL: &str = "zstd-stream-v1"; const COMPRESSION_LEVEL: i32 = -7; const WINDOW_LOG: u32 = 20; const BUFFER_SIZE: usize = 32 * 1024; +const STATS_INTERVAL: Duration = Duration::from_secs(60); +static NEXT_NATIVE_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WebSocketCompression { @@ -43,13 +52,17 @@ impl WebSocketCompression { pub struct ZstdEncoder { encoder: Encoder<'static>, + stats: Option, } impl ZstdEncoder { pub fn new() -> io::Result { let mut encoder = Encoder::new(COMPRESSION_LEVEL)?; encoder.set_parameter(CParameter::WindowLog(WINDOW_LOG))?; - Ok(Self { encoder }) + Ok(Self { + encoder, + stats: None, + }) } /// Flush one message without ending the frame or resetting its history. @@ -67,6 +80,9 @@ impl ZstdEncoder { let remaining = self.encoder.flush(&mut output)?; encoded.extend_from_slice(output.as_slice()); if remaining == 0 { + if let Some(stats) = &mut self.stats { + stats.record(text.len(), encoded.len()); + } return Ok(encoded); } } @@ -76,6 +92,7 @@ impl ZstdEncoder { pub struct ZstdDecoder { decoder: Decoder<'static>, max_message_size: usize, + stats: Option, } impl ZstdDecoder { @@ -85,12 +102,14 @@ impl ZstdDecoder { Ok(Self { decoder, max_message_size, + stats: None, }) } /// Decode one flushed message, retaining history and bounding its decoded size. /// Any error invalidates the stream; the caller must close the connection. pub fn decode(&mut self, mut input: &[u8]) -> io::Result { + let compressed_bytes = input.len(); let mut decoded = Vec::new(); let mut buffer = [0; BUFFER_SIZE]; loop { @@ -113,8 +132,97 @@ impl ZstdDecoder { break; } } - String::from_utf8(decoded) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + let text = String::from_utf8(decoded) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if let Some(stats) = &mut self.stats { + stats.record(text.len(), compressed_bytes); + } + Ok(text) + } +} + +/// Identifies the connection whose compression statistics are reported. +pub enum CompressionConnection { + Listener(ConnectionId), + NativeClient, +} + +/// Create codecs whose payload statistics share the transport connection ID. +/// TX counts encoded payloads, including those whose subsequent socket write fails. +pub fn zstd_codec_pair( + max_message_size: usize, + connection: CompressionConnection, +) -> io::Result<(ZstdEncoder, ZstdDecoder)> { + let mut encoder = ZstdEncoder::new()?; + let mut decoder = ZstdDecoder::new(max_message_size)?; + let (connection_id, connection_kind) = match connection { + CompressionConnection::Listener(connection_id) => (connection_id.0, "listener"), + CompressionConnection::NativeClient => ( + NEXT_NATIVE_CONNECTION_ID.fetch_add(1, Ordering::Relaxed), + "native_client", + ), + }; + encoder.stats = Some(CompressionStats::new(connection_id, connection_kind, "tx")); + decoder.stats = Some(CompressionStats::new(connection_id, connection_kind, "rx")); + Ok((encoder, decoder)) +} + +struct CompressionStats { + connection_id: u64, + connection_kind: &'static str, + direction: &'static str, + uncompressed_bytes: u64, + compressed_bytes: u64, + messages: u64, + last_report: Instant, +} + +impl CompressionStats { + fn new(connection_id: u64, connection_kind: &'static str, direction: &'static str) -> Self { + Self { + connection_id, + connection_kind, + direction, + uncompressed_bytes: 0, + compressed_bytes: 0, + messages: 0, + last_report: Instant::now(), + } + } + + fn record(&mut self, uncompressed_bytes: usize, compressed_bytes: usize) { + self.uncompressed_bytes += uncompressed_bytes as u64; + self.compressed_bytes += compressed_bytes as u64; + self.messages += 1; + if self.last_report.elapsed() >= STATS_INTERVAL { + self.report("periodic"); + self.last_report = Instant::now(); + } + } + + fn saved_percent(&self) -> Option { + (self.uncompressed_bytes != 0) + .then(|| 100.0 * (1.0 - self.compressed_bytes as f64 / self.uncompressed_bytes as f64)) + } + + fn report(&self, report: &'static str) { + tracing::info!( + connection_id = self.connection_id, + connection_kind = self.connection_kind, + direction = self.direction, + report, + uncompressed_bytes = self.uncompressed_bytes, + compressed_bytes = self.compressed_bytes, + messages = self.messages, + saved_percent = self.saved_percent(), + "app-server WebSocket compression payload totals" + ); + } +} + +impl Drop for CompressionStats { + fn drop(&mut self) { + self.report("final"); } } diff --git a/codex-rs/app-server-transport/src/websocket_compression_tests.rs b/codex-rs/app-server-transport/src/websocket_compression_tests.rs index 91a7c58ec42f..f3d61b1c41fd 100644 --- a/codex-rs/app-server-transport/src/websocket_compression_tests.rs +++ b/codex-rs/app-server-transport/src/websocket_compression_tests.rs @@ -1,5 +1,7 @@ //! Verify persistent history, peer fallback, and bounded decompression. +#![cfg(test)] + use super::*; use pretty_assertions::assert_eq; @@ -24,12 +26,36 @@ fn messages_keep_history_and_have_independent_size_limits() -> io::Result<()> { .map(|index| format!("thread-{index}: individual message body {index}")) .collect::>(), )?; - let mut encoder = ZstdEncoder::new()?; + let connection_id = ConnectionId(42); + let (mut encoder, mut decoder) = + zstd_codec_pair(text.len(), CompressionConnection::Listener(connection_id))?; + assert_eq!(encoder.stats.as_ref().unwrap().saved_percent(), None); let first = encoder.encode(&text)?; let second = encoder.encode(&text)?; - let mut decoder = ZstdDecoder::new(text.len())?; assert_eq!(decoder.decode(&first)?, text); assert_eq!(decoder.decode(&second)?, text); + let tx = encoder.stats.as_ref().unwrap(); + let rx = decoder.stats.as_ref().unwrap(); + assert_eq!( + (tx.connection_id, rx.connection_id), + (connection_id.0, connection_id.0) + ); + assert_eq!( + (tx.uncompressed_bytes, tx.compressed_bytes, tx.messages), + ( + (text.len() * 2) as u64, + (first.len() + second.len()) as u64, + 2 + ) + ); + assert_eq!( + (rx.uncompressed_bytes, rx.compressed_bytes, rx.messages), + (tx.uncompressed_bytes, tx.compressed_bytes, tx.messages) + ); + assert_eq!( + tx.saved_percent(), + Some(100.0 * (1.0 - (first.len() + second.len()) as f64 / (text.len() * 2) as f64)) + ); assert!(second.len() < first.len()); assert!(ZstdDecoder::new(text.len())?.decode(&second).is_err()); Ok(()) @@ -40,10 +66,13 @@ fn decoded_size_limit_covers_output_larger_than_scratch_buffer() -> io::Result<( let text = "abcdefgh".repeat(BUFFER_SIZE); let encoded = ZstdEncoder::new()?.encode(&text)?; assert_eq!(ZstdDecoder::new(text.len())?.decode(&encoded)?, text); - let error = ZstdDecoder::new(text.len() - 1)? + let (_, mut decoder) = zstd_codec_pair(text.len() - 1, CompressionConnection::NativeClient)?; + let error = decoder .decode(&encoded) .expect_err("oversize message must fail"); assert_eq!(error.kind(), io::ErrorKind::InvalidData); + let stats = decoder.stats.as_ref().unwrap(); + assert_eq!((stats.uncompressed_bytes, stats.compressed_bytes), (0, 0)); Ok(()) } From abf4bb91ec49fbf443ccec0069f9cab5dd1462d8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 25 Oct 2026 05:00:44 +0000 Subject: [PATCH 043/287] Expand keyboard navigation and selection in the transcript (#57221) ## What changed - Add word motions (`w`, `b`, `e`), logical line boundaries, page and half-page motions, and jumps to the transcript's beginning and end. Honor configured pager bindings for navigation. - Add whole-line selection with `V` and active-end swapping with `o`. - Adopt existing mouse selections when entering select mode, preserving selection units and endpoint bounds so keyboard motions can adjust the range. ## Testing Add regression tests for word motion across entries, reverse whole-line selection, empty-line boundaries, configured pager motion, final-grapheme selection, endpoint swapping, and caret placement after copying. GitOrigin-RevId: f0cd05900f2a7a15840777cee4d50659a2c8b134 --- .../tui/src/transcript_view/select_mode.rs | 166 ++++++++++++- .../src/transcript_view/select_mode/motion.rs | 234 ++++++++++++++++-- .../src/transcript_view/select_mode_tests.rs | 205 +++++++++++++++ codex-rs/tui/src/transcript_view/selection.rs | 4 + 4 files changed, 588 insertions(+), 21 deletions(-) diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs index 562a812b3b25..44e1d571a2cc 100644 --- a/codex-rs/tui/src/transcript_view/select_mode.rs +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -5,6 +5,7 @@ //! `Selection`. use super::*; +use crate::key_hint::KeyBindingListExt; use crate::text_selection::SelectionUnit; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -25,12 +26,52 @@ pub(super) struct SelectMode { pub(super) struct Mark { anchor: Anchor, active: Anchor, + anchor_edges: Option<(Anchor, Anchor)>, + active_edges: Option<(Anchor, Anchor)>, + unit: SelectionUnit, } #[derive(Clone, Copy)] enum CursorMotion { Character(KeyCode), Rows(isize), + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, + Beginning, + End, +} + +fn pager_motion( + keymap: &crate::keymap::RuntimeKeymap, + key: KeyEvent, + area_height: u16, +) -> Option { + let pager = &keymap.pager; + if pager.jump_top.is_pressed(key) { + return Some(CursorMotion::Beginning); + } + if pager.jump_bottom.is_pressed(key) { + return Some(CursorMotion::End); + } + let page = isize::try_from(area_height.saturating_sub(1).max(1)).unwrap_or(isize::MAX); + let half = isize::try_from(area_height.div_ceil(2)).unwrap_or(isize::MAX); + [ + (&pager.scroll_up, -1), + (&pager.scroll_down, 1), + (&pager.page_up, -page), + (&pager.page_down, page), + (&pager.half_page_up, -half), + (&pager.half_page_down, half), + ] + .into_iter() + .find_map(|(action_bindings, rows)| { + action_bindings + .is_pressed(key) + .then_some(CursorMotion::Rows(rows)) + }) } impl SelectMode { @@ -48,6 +89,7 @@ impl TranscriptView { } self.copy_mode = None; self.cancel_beginning(); + let has_range = self.has_selection_range(); if self.selection.is_none() { let cursor = self .visible @@ -74,6 +116,9 @@ impl TranscriptView { self.hold_position(); } self.select_mode = Some(SelectMode::default()); + if has_range { + self.adopt_select_mouse_range(); + } self.reveal_select_cursor(cells); true } @@ -134,17 +179,24 @@ impl TranscriptView { && matches!(code, KeyCode::Char('v') | KeyCode::Char(' ')) && modifiers == KeyModifiers::NONE { - self.start_select_mark(cells); + self.start_select_mark(cells, SelectionUnit::Character); + return Some(ViewAction::Changed); + } + if pressed && code == KeyCode::Char('v') && modifiers == KeyModifiers::SHIFT { + self.start_select_mark(cells, SelectionUnit::Line); + return Some(ViewAction::Changed); + } + if pressed && code == KeyCode::Char('o') && modifiers == KeyModifiers::NONE { + self.swap_select_ends(cells); return Some(ViewAction::Changed); } - let shift_arrow = modifiers == KeyModifiers::SHIFT && matches!( code, KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down ); if shift_arrow && self.select_mode.is_some_and(|mode| mode.mark.is_none()) { - self.start_select_mark(cells); + self.start_select_mark(cells, SelectionUnit::Character); } let motion = match (code, modifiers) { ( @@ -161,6 +213,38 @@ impl TranscriptView { (KeyCode::Char('l'), KeyModifiers::NONE) => CursorMotion::Character(KeyCode::Right), (KeyCode::Char('k'), KeyModifiers::NONE) => CursorMotion::Rows(-1), (KeyCode::Char('j'), KeyModifiers::NONE) => CursorMotion::Rows(1), + (KeyCode::Char('w'), KeyModifiers::NONE) => CursorMotion::WordForward, + (KeyCode::Char('b'), KeyModifiers::NONE) => CursorMotion::WordBackward, + (KeyCode::Char('e'), KeyModifiers::NONE) => CursorMotion::WordEnd, + (KeyCode::Home | KeyCode::Char('0'), KeyModifiers::NONE) => CursorMotion::LineStart, + (KeyCode::End, KeyModifiers::NONE) + | (KeyCode::Char('$'), KeyModifiers::NONE | KeyModifiers::SHIFT) => { + CursorMotion::LineEnd + } + (KeyCode::PageUp, KeyModifiers::NONE) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => { + CursorMotion::Rows( + -isize::try_from(self.area.height.saturating_sub(1).max(1)) + .unwrap_or(isize::MAX), + ) + } + (KeyCode::PageDown, KeyModifiers::NONE) + | (KeyCode::Char('f'), KeyModifiers::CONTROL) => CursorMotion::Rows( + isize::try_from(self.area.height.saturating_sub(1).max(1)).unwrap_or(isize::MAX), + ), + (KeyCode::Char('u'), KeyModifiers::CONTROL) => CursorMotion::Rows( + -isize::try_from(self.area.height.div_ceil(2)).unwrap_or(isize::MAX), + ), + (KeyCode::Char('d'), KeyModifiers::CONTROL) => CursorMotion::Rows( + isize::try_from(self.area.height.div_ceil(2)).unwrap_or(isize::MAX), + ), + (KeyCode::Char('g'), KeyModifiers::NONE) | (KeyCode::Home, KeyModifiers::CONTROL) => { + CursorMotion::Beginning + } + (KeyCode::Char('g'), KeyModifiers::SHIFT) => CursorMotion::End, + (KeyCode::End, KeyModifiers::CONTROL) => CursorMotion::End, + _ if !self.is_search_editing() => { + pager_motion(&self.disclosure.keymap, key, self.area.height)? + } _ => return None, }; self.move_select_cursor(cells, motion); @@ -172,7 +256,7 @@ impl TranscriptView { ViewAction::ExitSelectMode } - fn start_select_mark(&mut self, cells: &[Arc]) { + fn start_select_mark(&mut self, cells: &[Arc], unit: SelectionUnit) { let Some(mut mode) = self.select_mode else { return; }; @@ -181,13 +265,22 @@ impl TranscriptView { }; let cursor = mode.cursor(selection); mode.mark = match mode.mark { - Some(_) => { + Some(mark) if mark.unit == unit => { self.clear_select_mark(); return; } + Some(mark) => Some(Mark { + anchor_edges: None, + active_edges: None, + unit, + ..mark + }), None => Some(Mark { anchor: cursor, active: cursor, + anchor_edges: None, + active_edges: None, + unit, }), }; mode.preferred_column = None; @@ -208,7 +301,21 @@ impl TranscriptView { selection.set_keyboard_range(cursor, cursor); } - fn reveal_select_cursor(&mut self, cells: &[Arc]) { + fn swap_select_ends(&mut self, cells: &[Arc]) { + let Some(mode) = &mut self.select_mode else { + return; + }; + let Some(mark) = &mut mode.mark else { + return; + }; + std::mem::swap(&mut mark.anchor, &mut mark.active); + std::mem::swap(&mut mark.anchor_edges, &mut mark.active_edges); + mode.preferred_column = None; + self.sync_select_range(cells); + self.reveal_select_cursor(cells); + } + + pub(super) fn reveal_select_cursor(&mut self, cells: &[Arc]) { let Some(mode) = self.select_mode else { return; }; @@ -259,6 +366,53 @@ impl TranscriptView { buf.set_style(area, crate::style::selection_style().underlined()); } } + + pub(super) fn adopt_select_mouse_range(&mut self) { + let has_range = self.has_selection_range(); + let Some(selection) = &self.selection else { + return; + }; + let (start, end, unit) = (selection.start, selection.end, selection.unit()); + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or_default(); + let mark = has_range.then(|| { + if self.anchor_order(cells, start) <= self.anchor_order(cells, end) { + let active = self.before_grapheme(cells, end); + let anchor_end = self + .selection_endpoint_bounds(cells, start, unit) + .map_or(start, |edges| edges.1); + let active_start = self + .selection_endpoint_bounds(cells, active, unit) + .map_or(active, |edges| edges.0); + Mark { + anchor: start, + active, + anchor_edges: Some((start, anchor_end)), + active_edges: Some((active_start, end)), + unit, + } + } else { + let anchor = self.before_grapheme(cells, start); + let anchor_start = self + .selection_endpoint_bounds(cells, anchor, unit) + .map_or(anchor, |edges| edges.0); + let active_end = self + .selection_endpoint_bounds(cells, end, unit) + .map_or(end, |edges| edges.1); + Mark { + anchor, + active: end, + anchor_edges: Some((anchor_start, start)), + active_edges: Some((end, active_end)), + unit, + } + } + }); + if let Some(mode) = &mut self.select_mode { + mode.preferred_column = None; + mode.mark = mark; + } + } } #[cfg(test)] diff --git a/codex-rs/tui/src/transcript_view/select_mode/motion.rs b/codex-rs/tui/src/transcript_view/select_mode/motion.rs index d9198b93483a..911f29c62344 100644 --- a/codex-rs/tui/src/transcript_view/select_mode/motion.rs +++ b/codex-rs/tui/src/transcript_view/select_mode/motion.rs @@ -1,4 +1,4 @@ -//! Basic cursor motions and marked-range derivation for keyboard transcript selection. +//! Cursor motions and marked-range derivation for keyboard transcript selection. use super::*; use crossterm::event::KeyCode; @@ -13,7 +13,9 @@ impl TranscriptView { let Some(mut mode) = self.select_mode else { return; }; - self.cancel_beginning(); + if !matches!(motion, CursorMotion::Beginning) { + self.cancel_beginning(); + } let Some(selection) = self.selection.as_ref() else { return; }; @@ -39,6 +41,32 @@ impl TranscriptView { mode.preferred_column = column; self.move_select_rows(cells, cursor, rows, column) } + CursorMotion::WordForward => { + mode.preferred_column = None; + self.move_select_word( + cells, cursor, /*backward*/ false, /*to_end*/ false, + ) + } + CursorMotion::WordBackward => { + mode.preferred_column = None; + self.move_select_word(cells, cursor, /*backward*/ true, /*to_end*/ false) + } + CursorMotion::WordEnd => { + mode.preferred_column = None; + self.move_select_word(cells, cursor, /*backward*/ false, /*to_end*/ true) + } + CursorMotion::LineStart | CursorMotion::LineEnd => { + mode.preferred_column = None; + self.move_select_line(cells, cursor, matches!(motion, CursorMotion::LineEnd)) + } + CursorMotion::Beginning | CursorMotion::End => { + mode.preferred_column = None; + if matches!(motion, CursorMotion::Beginning) && self.history.has_unloaded_history() + { + self.jump_to_beginning(cells); + } + self.select_boundary(cells, matches!(motion, CursorMotion::End)) + } }; let Some(next) = next else { self.select_mode = Some(mode); @@ -46,6 +74,7 @@ impl TranscriptView { }; if let Some(mark) = &mut mode.mark { mark.active = next; + mark.active_edges = None; } else if let Some(selection) = &mut self.selection { selection.set_keyboard_range(next, next); } @@ -140,6 +169,96 @@ impl TranscriptView { Some(cursor) } + fn move_select_word( + &mut self, + cells: &[Arc], + cursor: Anchor, + backward: bool, + to_end: bool, + ) -> Option { + let mut index = self.resolve(cells, cursor); + let origin_index = index; + loop { + let layout = self.layout(cells, index)?; + let text = layout.text(); + let offset = if index == origin_index { + text.floor_char_boundary(cursor.offset.min(text.len())) + } else if backward { + text.len() + } else { + 0 + }; + let mut words = text + .split_word_bound_indices() + .filter(|(_, word)| word.chars().any(char::is_alphanumeric)); + let found = if backward { + words + .rfind(|(start, _)| *start < offset) + .map(|(start, _)| start) + } else if to_end { + words + .filter_map(|(start, word)| { + text[..start + word.len()] + .grapheme_indices(/*is_extended*/ true) + .next_back() + .map(|(offset, _)| offset) + }) + .find(|end| index != origin_index || *end > offset) + } else { + words + .find(|(start, _)| { + index != origin_index + || *start > offset + || (cursor.row_bias > 0 && *start == offset) + }) + .map(|(start, _)| start) + }; + if let Some(offset) = found { + return Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }); + } + if backward { + index = index.checked_sub(1)?; + } else { + index += 1; + self.layout(cells, index)?; + } + } + } + + fn move_select_line( + &mut self, + cells: &[Arc], + cursor: Anchor, + end: bool, + ) -> Option { + let index = self.resolve(cells, cursor); + let layout = self.layout(cells, index)?; + let range = SelectionUnit::Line.range(layout.text(), cursor.offset); + let offset = if end { + let end = range.end.saturating_sub(usize::from( + range.end > range.start + && layout.text().as_bytes().get(range.end - 1) == Some(&b'\n'), + )); + layout.text()[range.start..end] + .grapheme_indices(/*is_extended*/ true) + .next_back() + .map_or(range.start, |(offset, _)| range.start + offset) + } else { + range.start + }; + Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }) + } + pub(super) fn select_boundary( &mut self, cells: &[Arc], @@ -185,21 +304,56 @@ impl TranscriptView { let cells = snapshot.as_deref().unwrap_or(cells); let after_anchor = self.after_grapheme(cells, mark.anchor); let after_active = self.after_grapheme(cells, mark.active); + let origin = match mark.unit { + _ if mark.anchor_edges.is_some() => mark.anchor_edges, + SelectionUnit::Character => Some((mark.anchor, after_anchor)), + SelectionUnit::Word | SelectionUnit::Line => { + self.selection_bounds(cells, mark.anchor, mark.unit) + } + }; + let Some(origin) = origin else { return }; + let active_line = (mark.unit == SelectionUnit::Line) + .then(|| self.selection_bounds(cells, mark.active, SelectionUnit::Line)) + .flatten(); let Some(mut selection) = self.selection.take() else { return; }; - let (start, end) = - if self.anchor_order(cells, mark.anchor) <= self.anchor_order(cells, mark.active) { - (mark.anchor, after_active) - } else { - (after_anchor, mark.active) - }; - selection.set_keyboard_marked_range( - start, - end, - (mark.anchor, after_anchor), - SelectionUnit::Character, - ); + let anchor_order = self.anchor_order(cells, mark.anchor); + let active_order = self.anchor_order(cells, mark.active); + let forward = anchor_order <= active_order; + let (start, end) = match mark.unit { + SelectionUnit::Character | SelectionUnit::Word => { + if forward { + ( + origin.0, + mark.active_edges.map_or(after_active, |edges| edges.1), + ) + } else { + ( + mark.active_edges.map_or(mark.active, |edges| edges.0), + origin.1, + ) + } + } + SelectionUnit::Line => { + let Some(active) = active_line else { + self.selection = Some(selection); + return; + }; + if forward { + ( + origin.0, + mark.active_edges.map_or(active.1, |edges| edges.1), + ) + } else { + ( + mark.active_edges.map_or(active.0, |edges| edges.0), + origin.1, + ) + } + } + }; + selection.set_keyboard_marked_range(start, end, origin, mark.unit); self.pin_selection_range(cells, &mut selection); self.selection = Some(selection); } @@ -224,7 +378,57 @@ impl TranscriptView { } } - fn anchor_order(&self, cells: &[Arc], anchor: Anchor) -> (usize, usize) { + pub(super) fn before_grapheme( + &mut self, + cells: &[Arc], + anchor: Anchor, + ) -> Anchor { + self.move_select_character(cells, anchor, KeyCode::Left) + .unwrap_or(anchor) + } + + pub(super) fn selection_endpoint_bounds( + &mut self, + cells: &[Arc], + anchor: Anchor, + unit: SelectionUnit, + ) -> Option<(Anchor, Anchor)> { + if unit == SelectionUnit::Character { + return Some((anchor, self.after_grapheme(cells, anchor))); + } + self.selection_bounds(cells, anchor, unit) + } + + fn selection_bounds( + &mut self, + cells: &[Arc], + anchor: Anchor, + unit: SelectionUnit, + ) -> Option<(Anchor, Anchor)> { + let index = self.resolve(cells, anchor); + let layout = self.layout(cells, index)?; + let range = unit.range(layout.text(), anchor.offset); + Some(( + Anchor { + key: self.entry_key(cells, index), + index, + offset: range.start, + row_bias: 0, + }, + Anchor { + key: self.entry_key(cells, index), + index, + offset: range.end, + row_bias: 0, + }, + )) + } + + pub(super) fn anchor_order( + &self, + cells: &[Arc], + anchor: Anchor, + ) -> (usize, usize) { (self.resolve(cells, anchor), anchor.offset) } } diff --git a/codex-rs/tui/src/transcript_view/select_mode_tests.rs b/codex-rs/tui/src/transcript_view/select_mode_tests.rs index 61eed90e33b6..a2dc9202f6a8 100644 --- a/codex-rs/tui/src/transcript_view/select_mode_tests.rs +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::clipboard_copy::CopyStatus; use crate::history_cell::PlainHistoryCell; use pretty_assertions::assert_eq; use ratatui::text::Line; @@ -104,6 +105,57 @@ fn character_motion_crosses_line_and_entry_boundaries_without_invisible_stops() press(&mut view, &cells, KeyCode::Left); press(&mut view, &cells, KeyCode::Char('v')); assert_eq!(view.selected_text(&cells).as_deref(), Some("0")); + + let cells = vec![cell(&["ab"]), cell(&["cd"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + let start = Anchor { + key: EntryKey::cell(&cells[0]), + index: 0, + offset: 0, + row_bias: 0, + }; + let end = Anchor { + key: EntryKey::cell(&cells[1]), + index: 1, + offset: 0, + row_bias: 0, + }; + let mut selection = Selection::keyboard(view.capture_snapshot(&cells), start); + selection.set_keyboard_range(start, end); + view.selection = Some(selection); + assert!(view.begin_select_mode(&cells)); + assert_eq!( + view.select_mode + .and_then(|mode| mode.mark) + .expect("marked range") + .active, + Anchor { offset: 1, ..start } + ); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ab\n")); + press(&mut view, &cells, KeyCode::Char('o')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ab\n")); + press(&mut view, &cells, KeyCode::Char('l')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); + + let cells = vec![cell(&["abc"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + let start = Anchor { + key: EntryKey::cell(&cells[0]), + index: 0, + offset: 1, + row_bias: 0, + }; + let end = Anchor { offset: 3, ..start }; + let mut selection = Selection::keyboard(view.capture_snapshot(&cells), start); + selection.set_keyboard_range(start, end); + view.selection = Some(selection); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('h')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); + press(&mut view, &cells, KeyCode::Char('h')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ab")); } #[test] @@ -146,3 +198,156 @@ fn enter_without_a_mark_exits_but_enter_with_a_mark_copies_and_follows() { Some(ViewAction::CopyAndFollow(text)) if text == "c" )); } + +#[test] +fn word_motion_crosses_entries_and_word_end_selects_the_destination_word() { + let cells = vec![cell(&["alpha"]), cell(&["beta gamma"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + press(&mut view, &cells, KeyCode::Char('j')); + + press(&mut view, &cells, KeyCode::Char('w')); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Char('e')); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("beta")); +} + +#[test] +fn reverse_line_selection_copies_complete_lines_in_document_order() { + let cells = vec![cell(&["first", "second", "third"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + press(&mut view, &cells, KeyCode::Char('j')); + press(&mut view, &cells, KeyCode::Char('j')); + press(&mut view, &cells, KeyCode::Char('V')); + press(&mut view, &cells, KeyCode::Char('k')); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("second\nthird")); +} + +#[test] +fn logical_line_end_handles_empty_lines_and_shifted_dollar() { + let cells = vec![cell(&["abc", "", "z"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + assert!(matches!( + view.handle_key( + KeyEvent::new(KeyCode::Char('$'), KeyModifiers::SHIFT), + &cells, + ), + Some(ViewAction::Changed) + )); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("c")); + + press(&mut view, &cells, KeyCode::Esc); + press(&mut view, &cells, KeyCode::Char('j')); + press(&mut view, &cells, KeyCode::End); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells), None); + + let cells = vec![cell(&["abc", ""])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + press(&mut view, &cells, KeyCode::Char('j')); + press(&mut view, &cells, KeyCode::End); +} + +#[test] +fn configured_pager_page_motion_moves_the_select_cursor() { + let cells = vec![Arc::new(PlainHistoryCell::new( + (0..12).map(|row| format!("line {row}").into()).collect(), + )) as Arc]; + let config = serde_json::from_value(serde_json::json!({ + "pager": {"page_down": "x"} + })) + .expect("keymap config"); + let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("configured page down"); + let mut view = TranscriptView::default(); + view.set_keymap_bindings(&keymap); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + + press(&mut view, &cells, KeyCode::Char('v')); + assert!(view.select_mode.is_some_and(|mode| mode.mark.is_some())); + press(&mut view, &cells, KeyCode::Char('x')); + + let cursor = view + .select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")); + let layout = view.layout(&cells, cursor.index).expect("cursor layout"); + assert_eq!( + layout + .row_for_offset(cursor.offset) + .saturating_add_signed(cursor.row_bias.saturating_neg()), + 5 + ); +} + +#[test] +fn transcript_end_character_selection_includes_the_last_grapheme() { + let cells = vec![cell(&["start"]), cell(&["tail🙂"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('G')); + press(&mut view, &cells, KeyCode::Char('v')); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("🙂")); +} + +#[test] +fn swapping_the_active_end_preserves_text_and_moves_the_shrinking_endpoint() { + let cells = vec![cell(&["abcdef"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Char('o')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("a")); + press(&mut view, &cells, KeyCode::Char('o')); + for _ in 0..3 { + press(&mut view, &cells, KeyCode::Char('l')); + } + assert_eq!(view.selected_text(&cells).as_deref(), Some("abcd")); + press(&mut view, &cells, KeyCode::Char('o')); + press(&mut view, &cells, KeyCode::Char('l')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("bcd")); +} + +#[test] +fn confirmed_copy_keeps_the_select_mode_caret_on_the_active_grapheme() { + let cells = vec![cell(&["abc"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Char('l')); + let Some(ViewAction::Copy(text)) = view.handle_key(KeyCode::Char('y').into(), &cells) else { + panic!("copy key should copy the marked range"); + }; + assert_eq!(text, "ab"); + assert_eq!( + view.copy_selected_text_with( + &cells, + &text, + /*clear_selection*/ true, + |_, _| { Ok(CopyStatus::Confirmed) } + ), + Ok(CopyStatus::Confirmed) + ); + assert!(view.select_mode.is_some()); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); +} diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index 47ee87dd94ab..9cd97930341d 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -83,6 +83,10 @@ impl Selection { self.origin = origin; self.unit = unit; } + + pub(super) fn unit(&self) -> SelectionUnit { + self.unit + } } impl TranscriptView { From 285fc743050f739eff7c71f34d8dc9e0dd5c0bb2 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 25 Oct 2026 05:00:45 +0000 Subject: [PATCH 044/287] Integrate Find with transcript selection mode (#57222) ## What changed - Support `/` to open Find in selection mode and `Enter` to move the cursor to the match, extending an active selection. - Repeat the accepted search from the cursor with `n` for older matches and `Shift+n` for newer matches. Cancel pending repeats when the cursor or selection changes. - Restore the previous search, cursor, and selection when cancelling a new search, and reuse the previous search when accepting an empty query. - Align the cursor to selectable graphemes and preserve expanded matches when leaving selection mode. ## Testing Add regression coverage for search acceptance and cancellation, repeated searches, Unicode cursor placement, expanded output retention, and history pages loaded while a search is saved. GitOrigin-RevId: 077be52a54d5e006f65eacc4634baf8c089d3662 --- codex-rs/tui/src/transcript_view/input.rs | 15 + codex-rs/tui/src/transcript_view/search.rs | 236 +++++++++++++++- .../transcript_view/search_presentation.rs | 3 + .../tui/src/transcript_view/search_tests.rs | 15 +- .../tui/src/transcript_view/select_mode.rs | 101 ++++++- .../src/transcript_view/select_mode/motion.rs | 1 + .../src/transcript_view/select_mode_tests.rs | 259 +++++++++++++++++- 7 files changed, 604 insertions(+), 26 deletions(-) diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 6e3ebd290d7f..c52b388d9ade 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -202,6 +202,21 @@ impl TranscriptView { return Some(action); } if self.select_mode.is_some() { + if self.is_search_editing() { + let (code, modifiers) = + crate::key_hint::normalize_key_parts(key.code, key.modifiers); + let accepting = code == KeyCode::Enter && modifiers == KeyModifiers::NONE; + let cancelling = code == KeyCode::Esc + || (code == KeyCode::Char('c') && modifiers == KeyModifiers::CONTROL); + if self.handle_search_key(key, cells) { + if accepting && self.search.is_reading() { + self.accept_search_into_select_mode(cells); + } else if cancelling { + self.restore_select_search_origin(); + } + return Some(ViewAction::Changed); + } + } return self .handle_select_mode_key(key, cells) .or(Some(ViewAction::Changed)); diff --git a/codex-rs/tui/src/transcript_view/search.rs b/codex-rs/tui/src/transcript_view/search.rs index b69e3c536c77..7511992c3967 100644 --- a/codex-rs/tui/src/transcript_view/search.rs +++ b/codex-rs/tui/src/transcript_view/search.rs @@ -13,6 +13,7 @@ use crossterm::event::KeyModifiers; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; +use super::select_mode::SelectSearchOrigin; use super::*; #[path = "search_footer.rs"] @@ -47,12 +48,20 @@ enum Progress { Exhausted, } +#[derive(Clone)] struct Match { anchor: Anchor, end: usize, layout: Arc, } +struct SavedSelectSearch { + query: String, + current: Option, + progress: Progress, + page_start: Option, +} + #[derive(Clone, Copy, Eq, PartialEq)] enum SearchMode { Closed, @@ -68,6 +77,7 @@ pub(super) struct Search { saved_snapshot: Option, saved_detailed: bool, current: Option, + saved_select: Option, progress: Progress, scanning_layout: Option<(EntryKey, Arc)>, page_start: Option, @@ -86,6 +96,7 @@ impl Default for Search { saved_snapshot: None, saved_detailed: false, current: None, + saved_select: None, progress: Progress::Idle, scanning_layout: None, page_start: None, @@ -103,13 +114,42 @@ impl TranscriptView { return; } self.copy_mode = None; + if self.search.is_reading() { + self.pin_current_search_match(); + } + if let (Some(mode), Some(selection)) = (&mut self.select_mode, &mut self.selection) { + let cursor = mode.cursor(selection); + mode.search_origin = Some(SelectSearchOrigin { + mark: mode.mark, + preferred_column: mode.preferred_column, + start: selection.start, + end: selection.end, + }); + mode.mark = None; + selection.set_keyboard_range(cursor, cursor); + } let saved_position = self.position; - let saved_snapshot = self - .selection - .take() - .map(|selection| selection.snapshot) - .or(self.held_reading.take()); + let saved_snapshot = if self.select_mode.is_some() { + None + } else { + self.selection + .take() + .map(|selection| selection.snapshot) + .or(self.held_reading.take()) + }; if self.search.is_reading() { + if self.select_mode.is_some() { + self.search.saved_select = Some(SavedSelectSearch { + query: self.search.editor.text().to_string(), + current: self.search.current.clone(), + progress: self.search.progress, + page_start: self.search.page_start, + }); + self.search.editor.set_text_clearing_elements(""); + self.search.saved_position = saved_position; + self.search.saved_snapshot = None; + self.search.saved_detailed = self.detailed; + } self.held_reading = saved_snapshot; self.search.mode = SearchMode::Editing; if self.search.current.is_none() { @@ -161,6 +201,9 @@ impl TranscriptView { return modifiers == KeyModifiers::NONE; } (KeyCode::Enter, KeyModifiers::NONE) => { + if self.search.editor.is_empty() && self.restore_saved_select_search() { + return true; + } // Accept the displayed match even if finding an older one is still pending. // An idle nonempty query can also mean scrolling released a live match. // Leave initial scans and empty/unmatched queries open until they find a hit. @@ -175,6 +218,7 @@ impl TranscriptView { _ => Progress::Idle, }; self.search.scanning_layout = None; + self.search.saved_select = None; } } (KeyCode::Char('n'), KeyModifiers::CONTROL) => { @@ -233,6 +277,9 @@ impl TranscriptView { if !self.search.is_active() { return; } + if self.select_mode.is_some() && self.restore_saved_select_search() { + return; + } let position = self.search.saved_position; let snapshot = self.search.saved_snapshot.take().or_else(|| { let Position::Reading(anchor) = position else { @@ -256,6 +303,7 @@ impl TranscriptView { self.search.editor.set_text_clearing_elements(""); self.search.query_changed(); self.search.progress = Progress::Idle; + self.search.saved_select = None; self.search.live = None; self.search.live_key = None; self.position = position; @@ -263,6 +311,122 @@ impl TranscriptView { self.rewrap_snapshot(self.area.width); } + pub(super) fn accept_search_into_select_mode(&mut self, cells: &[Arc]) { + let Some(found) = self.search.current.as_ref() else { + self.search.mode = SearchMode::Editing; + return; + }; + let anchor = found.anchor; + let layout = Arc::clone(&found.layout); + self.move_select_cursor_to_search_match(anchor, &layout, cells); + } + + pub(super) fn restore_select_search_origin(&mut self) { + let (Some(mode), Some(selection)) = (&mut self.select_mode, &mut self.selection) else { + return; + }; + let Some(origin) = mode.search_origin.take() else { + return; + }; + mode.mark = origin.mark; + mode.preferred_column = origin.preferred_column; + selection.set_keyboard_range(origin.start, origin.end); + } + + pub(super) fn cancel_pending_select_search(&mut self) { + if self.search.is_reading() + && matches!( + self.search.progress, + Progress::Scanning(_) | Progress::AwaitingHistory + ) + { + self.search.progress = if self.search.current.is_some() { + Progress::Found + } else { + Progress::Idle + }; + self.search.scanning_layout = None; + } + } + + pub(super) fn repeat_select_search( + &mut self, + cells: &[Arc], + newer: bool, + ) -> bool { + if !self.search.is_reading() { + return false; + } + let direction = if newer { + Direction::Newer + } else { + Direction::Older + }; + let Some(cursor) = self + .select_mode + .zip(self.selection.as_ref()) + .map(|(mode, selection)| mode.cursor(selection)) + else { + return false; + }; + self.pin_current_search_match(); + let index = self.resolve(cells, cursor); + let Some(layout) = self.layout(cells, index) else { + return false; + }; + let offset = layout + .text() + .floor_char_boundary(cursor.offset.min(layout.text().len())); + let offset = if direction == Direction::Newer { + let row = layout + .row_for_offset(offset) + .saturating_add_signed(cursor.row_bias.saturating_neg()); + layout.after_cursor_grapheme(row, offset) + } else { + offset + }; + self.search.current = None; + self.search.scanning_layout = None; + self.search.page_start = None; + self.search.progress = Progress::Scanning(Cursor { + anchor: Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }, + direction, + }); + true + } + + /// Drop Find's transient expansion after cursor mode exits while preserving its paused view. + pub(super) fn close_search_after_select_mode(&mut self) { + if !self.search.is_active() { + return; + } + self.pin_current_search_match(); + self.search.mode = SearchMode::Closed; + self.search.editor.set_text_clearing_elements(""); + self.search.query_changed(); + self.search.progress = Progress::Idle; + self.search.saved_select = None; + self.search.live = None; + self.search.live_key = None; + self.rewrap_snapshot(self.area.width); + } + + fn pin_current_search_match(&mut self) { + let retained_match = self + .search + .current + .as_ref() + .map(|found| (found.anchor.key, Arc::clone(&found.layout))); + if let (Some((key, layout)), Some(snapshot)) = (retained_match, self.snapshot_mut()) { + snapshot.pinned.insert(key, layout); + } + } + pub(crate) fn paste_search(&mut self, text: &str) -> bool { if !self.is_search_editing() { return false; @@ -334,6 +498,14 @@ impl TranscriptView { if let Some(found) = &mut self.search.current { found.layout = Arc::new(found.layout.rewrap(width)); } + if let Some(found) = self + .search + .saved_select + .as_mut() + .and_then(|saved| saved.current.as_mut()) + { + found.layout = Arc::new(found.layout.rewrap(width)); + } self.area.width = width; if self.search.current.is_none() || !matches!(self.search.progress, Progress::Found | Progress::Exhausted) @@ -342,9 +514,17 @@ impl TranscriptView { } } + fn restore_saved_select_search(&mut self) -> bool { + if !self.search.restore_saved_select() { + return false; + } + self.position = self.search.saved_position; + true + } + /// Return true only while another local scanning frame can make progress. pub(crate) fn advance_search(&mut self, cells: &[Arc]) -> bool { - if self.selection.is_some() { + if self.selection.is_some() && self.select_mode.is_none() { return false; } let current_cells = cells; @@ -427,11 +607,11 @@ impl TranscriptView { ..anchor }); } - if !matches!(self.search.progress, Progress::AwaitingHistory) || inserted.is_empty() { + if inserted.is_empty() { return; } let index = inserted.end - 1; - self.search.progress = Progress::Scanning(Cursor { + let cursor = Cursor { anchor: Anchor { key: self.entry_key(cells, index), index, @@ -439,14 +619,34 @@ impl TranscriptView { row_bias: 0, }, direction: Direction::Older, - }); + }; + let page_start = self.entry_key(cells, inserted.start); + if let Some(saved) = &mut self.search.saved_select + && matches!( + saved.progress, + Progress::AwaitingHistory + | Progress::Scanning(Cursor { + direction: Direction::Older, + .. + }) + ) + { + if matches!(saved.progress, Progress::AwaitingHistory) { + saved.progress = Progress::Scanning(cursor); + } + saved.page_start = Some(page_start); + } + if !matches!(self.search.progress, Progress::AwaitingHistory) { + return; + } + self.search.progress = Progress::Scanning(cursor); self.search.scanning_layout = None; - self.search.page_start = Some(self.entry_key(cells, inserted.start)); + self.search.page_start = Some(page_start); } pub(super) fn render_search(&self, buf: &mut Buffer) { // The copy range takes precedence until selection ends. - if self.selection.is_some() { + if !self.search.is_active() || self.has_selection_range() { return; } let Some(found) = &self.search.current else { @@ -700,6 +900,20 @@ impl Search { self.progress = Progress::Restart; self.query_truncated = false; } + + fn restore_saved_select(&mut self) -> bool { + let Some(saved) = self.saved_select.take() else { + return false; + }; + self.editor.set_text_clearing_elements(&saved.query); + self.folded_query = saved.query.chars().flat_map(char::to_lowercase).collect(); + self.current = saved.current; + self.progress = saved.progress; + self.scanning_layout = None; + self.page_start = saved.page_start; + self.mode = SearchMode::Reading; + true + } } fn scan_window(text: &str, cursor: Cursor, query_len: usize) -> Range { diff --git a/codex-rs/tui/src/transcript_view/search_presentation.rs b/codex-rs/tui/src/transcript_view/search_presentation.rs index fc8963bce34c..912d2a0e9f7c 100644 --- a/codex-rs/tui/src/transcript_view/search_presentation.rs +++ b/codex-rs/tui/src/transcript_view/search_presentation.rs @@ -31,6 +31,9 @@ impl TranscriptView { end, layout: Arc::clone(&layout), }); + if self.search.is_reading() { + self.move_select_cursor_to_search_match(anchor, &layout, current_cells); + } let row = layout.row_for_offset(anchor.offset); let match_rows = layout.row_for_offset(end - 1) - row + 1; let context_rows = usize::from(self.area.height / 3) diff --git a/codex-rs/tui/src/transcript_view/search_tests.rs b/codex-rs/tui/src/transcript_view/search_tests.rs index 1ddcb869b9b8..8308ca55c18d 100644 --- a/codex-rs/tui/src/transcript_view/search_tests.rs +++ b/codex-rs/tui/src/transcript_view/search_tests.rs @@ -886,9 +886,20 @@ fn search_scans_a_page_inserted_after_the_retained_session_header() { view.paste_search("needle"); finish_scan(&mut view, &cells); assert!(view.search.needs_history(view.history)); - cells.insert(/*index*/ 1, cell("older NEEDLE")); + view.search.saved_select = Some(SavedSelectSearch { + query: "needle".to_string(), + current: None, + progress: view.search.progress, + page_start: view.search.page_start, + }); + view.search.progress = Progress::Restart; + cells.insert(/*index*/ 1, cell("older messages")); + view.history = TranscriptHistoryState::Partial; + view.history_loaded(&cells, 1..2); + cells.insert(/*index*/ 1, cell("oldest NEEDLE")); view.history = TranscriptHistoryState::Complete; view.history_loaded(&cells, 1..2); + assert!(view.search.restore_saved_select()); finish_scan(&mut view, &cells); let found = view .search @@ -902,7 +913,7 @@ fn search_scans_a_page_inserted_after_the_retained_session_header() { found.anchor.offset, found.end ), - (EntryKey::cell(&cells[1]), 1, 6, 12) + (EntryKey::cell(&cells[1]), 1, 7, 13) ); assert!(Arc::ptr_eq(&cells[0], &header)); } diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs index 44e1d571a2cc..5a2077f7ae22 100644 --- a/codex-rs/tui/src/transcript_view/select_mode.rs +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -13,6 +13,7 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use unicode_segmentation::UnicodeSegmentation; mod motion; @@ -20,6 +21,7 @@ mod motion; pub(super) struct SelectMode { pub(super) mark: Option, pub(super) preferred_column: Option, + pub(super) search_origin: Option, } #[derive(Clone, Copy)] @@ -31,6 +33,14 @@ pub(super) struct Mark { unit: SelectionUnit, } +#[derive(Clone, Copy)] +pub(super) struct SelectSearchOrigin { + pub(super) mark: Option, + pub(super) preferred_column: Option, + pub(super) start: Anchor, + pub(super) end: Anchor, +} + #[derive(Clone, Copy)] enum CursorMotion { Character(KeyCode), @@ -81,7 +91,7 @@ impl SelectMode { } impl TranscriptView { - /// Enter at the bottommost visible selectable position. + /// Enter at the accepted Find hit, preferring the bottommost visible selectable position. #[cfg_attr(not(test), expect(dead_code, reason = "used by a later stack layer"))] pub(crate) fn begin_select_mode(&mut self, cells: &[Arc]) -> bool { if self.select_mode.is_some() { @@ -92,17 +102,18 @@ impl TranscriptView { let has_range = self.has_selection_range(); if self.selection.is_none() { let cursor = self - .visible - .iter() - .rev() - .find_map(|visible| { - let offset = visible.layout.first_cursor_position(visible.row)?; - Some(Anchor { - key: visible.key, - index: visible.index, - offset, - row_bias: visible.layout.row_for_offset(offset) as isize - - visible.row as isize, + .search + .match_anchor() + .or_else(|| { + self.visible.iter().rev().find_map(|visible| { + let offset = visible.layout.first_cursor_position(visible.row)?; + Some(Anchor { + key: visible.key, + index: visible.index, + offset, + row_bias: visible.layout.row_for_offset(offset) as isize + - visible.row as isize, + }) }) }) .or_else(|| self.select_boundary(cells, /*end*/ true)); @@ -118,6 +129,8 @@ impl TranscriptView { self.select_mode = Some(SelectMode::default()); if has_range { self.adopt_select_mouse_range(); + } else if self.search.is_reading() { + self.accept_search_into_select_mode(cells); } self.reveal_select_cursor(cells); true @@ -175,6 +188,10 @@ impl TranscriptView { if pressed && code == KeyCode::Char('q') && modifiers == KeyModifiers::NONE { return Some(self.exit_select_mode()); } + if pressed && code == KeyCode::Char('/') && modifiers == KeyModifiers::NONE { + self.begin_search(); + return Some(ViewAction::Changed); + } if pressed && matches!(code, KeyCode::Char('v') | KeyCode::Char(' ')) && modifiers == KeyModifiers::NONE @@ -190,6 +207,13 @@ impl TranscriptView { self.swap_select_ends(cells); return Some(ViewAction::Changed); } + if code == KeyCode::Char('n') + && matches!(modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) + && self.repeat_select_search(cells, modifiers == KeyModifiers::SHIFT) + { + return Some(ViewAction::Changed); + } + let shift_arrow = modifiers == KeyModifiers::SHIFT && matches!( code, @@ -253,10 +277,12 @@ impl TranscriptView { fn exit_select_mode(&mut self) -> ViewAction { self.end_select_mode(); + self.close_search_after_select_mode(); ViewAction::ExitSelectMode } fn start_select_mark(&mut self, cells: &[Arc], unit: SelectionUnit) { + self.cancel_pending_select_search(); let Some(mut mode) = self.select_mode else { return; }; @@ -289,6 +315,7 @@ impl TranscriptView { } fn clear_select_mark(&mut self) { + self.cancel_pending_select_search(); let Some(mode) = &mut self.select_mode else { return; }; @@ -302,6 +329,7 @@ impl TranscriptView { } fn swap_select_ends(&mut self, cells: &[Arc]) { + self.cancel_pending_select_search(); let Some(mode) = &mut self.select_mode else { return; }; @@ -368,6 +396,7 @@ impl TranscriptView { } pub(super) fn adopt_select_mouse_range(&mut self) { + self.cancel_pending_select_search(); let has_range = self.has_selection_range(); let Some(selection) = &self.selection else { return; @@ -413,6 +442,54 @@ impl TranscriptView { mode.mark = mark; } } + + pub(super) fn move_select_cursor_to_search_match( + &mut self, + mut anchor: Anchor, + layout: &TextLayout, + cells: &[Arc], + ) { + let text = layout.text(); + let containing = text + .grapheme_indices(/*is_extended*/ true) + .rfind(|(offset, _)| *offset <= anchor.offset) + .map(|(offset, _)| offset); + anchor.offset = containing + .filter(|offset| layout.is_cursor_position(*offset)) + .or_else(|| { + text.grapheme_indices(/*is_extended*/ true) + .map(|(offset, _)| offset) + .find(|offset| *offset >= anchor.offset && layout.is_cursor_position(*offset)) + }) + .or_else(|| { + text.grapheme_indices(/*is_extended*/ true) + .map(|(offset, _)| offset) + .rfind(|offset| layout.is_cursor_position(*offset)) + }) + .unwrap_or(anchor.offset); + + let (Some(mut mode), Some(mut selection)) = + (self.select_mode.take(), self.selection.take()) + else { + return; + }; + if let Some(origin) = mode.search_origin.take() { + mode.mark = origin.mark; + mode.preferred_column = origin.preferred_column; + selection.set_keyboard_range(origin.start, origin.end); + } + mode.preferred_column = None; + if let Some(mark) = &mut mode.mark { + mark.active = anchor; + mark.active_edges = None; + } else { + selection.set_keyboard_range(anchor, anchor); + } + self.select_mode = Some(mode); + self.selection = Some(selection); + self.sync_select_range(cells); + self.reveal_select_cursor(cells); + } } #[cfg(test)] diff --git a/codex-rs/tui/src/transcript_view/select_mode/motion.rs b/codex-rs/tui/src/transcript_view/select_mode/motion.rs index 911f29c62344..ff36090b841d 100644 --- a/codex-rs/tui/src/transcript_view/select_mode/motion.rs +++ b/codex-rs/tui/src/transcript_view/select_mode/motion.rs @@ -10,6 +10,7 @@ impl TranscriptView { cells: &[Arc], motion: CursorMotion, ) { + self.cancel_pending_select_search(); let Some(mut mode) = self.select_mode else { return; }; diff --git a/codex-rs/tui/src/transcript_view/select_mode_tests.rs b/codex-rs/tui/src/transcript_view/select_mode_tests.rs index a2dc9202f6a8..1694572aa282 100644 --- a/codex-rs/tui/src/transcript_view/select_mode_tests.rs +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -12,7 +12,6 @@ fn cell(lines: &[&str]) -> Arc { .collect(), )) } - fn render(view: &mut TranscriptView, cells: &[Arc]) -> Buffer { let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 6, @@ -351,3 +350,261 @@ fn confirmed_copy_keeps_the_select_mode_caret_on_the_active_grapheme() { press(&mut view, &cells, KeyCode::Char('v')); assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); } + +#[test] +fn find_accepts_at_the_match_and_cancel_restores_the_saved_selection() { + let mut cells = vec![cell(&["alpha beta gamma"])]; + for _ in 0..6 { + cells.push(cell(&["filler"])); + } + cells.push(cell(&["distant"])); + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("gamma")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + let accepted_cursor = view + .select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")); + let accepted_position = view.position; + + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("distant")); + while view.advance_search(&cells) {} + render(&mut view, &cells); + press(&mut view, &cells, KeyCode::Esc); + + assert_eq!(view.position, accepted_position); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")), + accepted_cursor + ); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Right); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ga")); + + press(&mut view, &cells, KeyCode::Char('/')); + let narrow = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 6, + ); + view.render(narrow, &mut Buffer::empty(narrow), &cells); + press(&mut view, &cells, KeyCode::Esc); + view.render(narrow, &mut Buffer::empty(narrow), &cells); + + press(&mut view, &cells, KeyCode::Char('/')); + assert!(matches!( + view.handle_key( + KeyEvent::new(KeyCode::Char('\u{3}'), KeyModifiers::NONE), + &cells, + ), + Some(ViewAction::Changed) + )); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ga")); + + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("alpha")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 0 + ); + assert_eq!(view.selected_text(&cells).as_deref(), Some("alpha beta g")); + + let key = accepted_cursor.key; + view.select_mode = None; + view.selection + .as_mut() + .expect("selection") + .set_keyboard_range( + Anchor { + offset: 6, + ..accepted_cursor + }, + Anchor { + key, + offset: 10, + ..accepted_cursor + }, + ); + assert!(view.begin_select_mode(&cells)); + assert_eq!(view.selected_text(&cells).as_deref(), Some("beta")); + + for (query, expected_offset, expected_text) in [("\u{301}", 0, "a\u{301}"), ("\nbeta", 4, "b")] + { + let cells = vec![cell(&["a\u{301}", "beta"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search(query)); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + expected_offset + ); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some(expected_text)); + } +} + +#[test] +fn accepted_find_repeats_into_the_select_cursor() { + let cells = vec![cell(&["needle first needle second"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("needle")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 13 + ); + + press(&mut view, &cells, KeyCode::Char('/')); + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 13 + ); + + press(&mut view, &cells, KeyCode::Char('n')); + press(&mut view, &cells, KeyCode::Right); + while view.advance_search(&cells) {} + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 14 + ); + + press(&mut view, &cells, KeyCode::Char('n')); + while view.advance_search(&cells) {} + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 0 + ); + + press(&mut view, &cells, KeyCode::Char('v')); + assert!(matches!( + view.handle_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::SHIFT), + &cells, + ), + Some(ViewAction::Changed) + )); + while view.advance_search(&cells) {} + let mode = view.select_mode.expect("select mode"); + assert!(mode.mark.is_some()); + assert_eq!( + mode.cursor(view.selection.as_ref().expect("selection")) + .offset, + 13 + ); +} + +#[test] +fn accepted_find_pins_an_expanded_match_before_select_mode_exits() { + let output = format!( + "{}hidden needle\n{}", + "head\n".repeat(/*n*/ 20), + "tail\n".repeat(/*n*/ 20) + ); + let command = crate::exec_cell::ExecCell::new( + crate::exec_cell::ExecCall { + call_id: "command".to_string(), + command: vec!["echo".to_string()], + parsed: Vec::new(), + output: Some(crate::exec_cell::CommandOutput::new( + /*exit_code*/ 0, output, + )), + source: codex_app_server_protocol::CommandExecutionSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ false, + ); + let cells = vec![Arc::new(command) as Arc]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("needle")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + + let anchor = view.search.match_anchor().expect("accepted hidden match"); + render(&mut view, &cells); + let cursor = view + .select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")); + let index = view.resolve(&cells, cursor); + let row = view + .layout(&cells, index) + .expect("cursor layout") + .row_for_offset(cursor.offset); + assert!( + view.visible + .iter() + .any(|visible| visible.index == index && visible.row == row) + ); + let expanded = view + .search + .match_layout(anchor.key) + .expect("expanded search layout") + .text() + .to_owned(); + press(&mut view, &cells, KeyCode::Char('/')); + press(&mut view, &cells, KeyCode::Esc); + assert_eq!( + view.selection + .as_ref() + .expect("selection") + .snapshot + .pinned + .get(&anchor.key) + .expect("expanded origin pinned") + .text(), + expanded + ); + assert!(matches!( + view.handle_key(KeyCode::Char('q').into(), &cells), + Some(ViewAction::ExitSelectMode) + )); + assert_eq!( + view.held_reading + .as_ref() + .expect("paused snapshot") + .pinned + .get(&anchor.key) + .expect("expanded match pinned") + .text(), + expanded + ); +} From 9f622c2abb676fb5d6764eb92034f5282c7934e9 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 25 Oct 2026 05:00:45 +0000 Subject: [PATCH 045/287] Wire keyboard selection into transcript overlays (#57223) ## What changed - Add `tui.keymap.global.select_transcript` with an implicit `leader [` shortcut that follows the configured leader, respects explicit remapping or unbinding, and yields to existing bindings. - Connect transcript overlays to keyboard selection, including Find, mouse selection, and navigation to the beginning of paged history. Give selection commands priority over remapped pager shortcuts. - Close selection overlays on exit or after a confirmed copy-and-follow operation, preserving the pending close decision while clipboard confirmation is asynchronous. - Show selection navigation and copy hints that adapt to the available footer width. ## Testing Add coverage for shortcut dispatch, remapping, unbinding, and conflicts; overlay exit behavior; selection across history paging; mouse and keyboard selection interaction; and cursor and footer rendering snapshots. GitOrigin-RevId: 8a920e5fa2b06ff0544d829ba30f8ecb654db0ee --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 + codex-rs/tui/src/keymap.rs | 62 +++++- codex-rs/tui/src/keymap/bindings.rs | 13 +- codex-rs/tui/src/keymap/chords.rs | 47 ++++- .../tui/src/keymap/chords/leader_defaults.rs | 82 ++++++++ .../src/keymap/chords/quick_dial_defaults.rs | 68 ------ .../tui/src/keymap/select_transcript_tests.rs | 99 +++++++++ codex-rs/tui/src/pager_overlay/transcript.rs | 135 ++++++++++-- .../tui/src/pager_overlay/transcript_tests.rs | 81 ++++++++ codex-rs/tui/src/transcript_view/footer.rs | 42 +++- codex-rs/tui/src/transcript_view/input.rs | 20 +- .../tui/src/transcript_view/select_mode.rs | 12 +- .../src/transcript_view/select_mode_tests.rs | 196 ++++++++++++++++++ ...ers_cursor_and_scannable_footer_hints.snap | 99 +++++++++ 15 files changed, 869 insertions(+), 99 deletions(-) create mode 100644 codex-rs/tui/src/keymap/chords/leader_defaults.rs delete mode 100644 codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs create mode 100644 codex-rs/tui/src/keymap/select_transcript_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__select_mode_renders_cursor_and_scannable_footer_hints.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 170dcf1b446d..6fed3e356144 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -141,6 +141,8 @@ pub struct TuiGlobalKeymap { pub open_transcript: Option, /// Find text in the full transcript. pub find_transcript: Option, + /// Enter keyboard selection mode for the transcript. + pub select_transcript: Option, /// Focus activity groups in the owned transcript to inspect their details. pub focus_activity: Option, /// Open the external editor for the current draft. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7b101bd2616c..3dd9740369bb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5152,6 +5152,7 @@ "open_external_editor": null, "open_transcript": null, "queue": null, + "select_transcript": null, "submit": null, "toggle_fast_mode": null, "toggle_raw_output": null, @@ -6007,6 +6008,14 @@ ], "description": "Queue the current composer draft while a task is running." }, + "select_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter keyboard selection mode for the transcript." + }, "submit": { "allOf": [ { @@ -6176,6 +6185,7 @@ "open_external_editor": null, "open_transcript": null, "queue": null, + "select_transcript": null, "submit": null, "toggle_fast_mode": null, "toggle_raw_output": null, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index c185f40f02df..884cfe34ba62 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -50,6 +50,10 @@ mod voice_tests; #[path = "keymap/global_find_tests.rs"] mod global_find_tests; +#[cfg(test)] +#[path = "keymap/select_transcript_tests.rs"] +mod select_transcript_tests; + pub(crate) use bindings::KeymapActionId; pub(crate) use bindings::KeymapContext; pub(crate) use bindings::bindings_for_action; @@ -102,6 +106,8 @@ pub(crate) struct AppKeymap { pub(crate) open_transcript: Vec, /// Find text in the full transcript. pub(crate) find_transcript: Vec, + /// Enter keyboard selection mode for the transcript. + pub(crate) select_transcript: Vec, /// Focus activity groups in the owned transcript to inspect their details. pub(crate) focus_activity: Vec, /// Open retained warnings without replacing the composer. @@ -723,6 +729,11 @@ impl RuntimeKeymap { &defaults.app.find_transcript, "tui.keymap.global.find_transcript", )?, + select_transcript: resolve_bindings( + keymap.global.select_transcript.as_ref(), + &defaults.app.select_transcript, + "tui.keymap.global.select_transcript", + )?, focus_activity: resolve_bindings( keymap.global.focus_activity.as_ref(), &focus_activity_defaults, @@ -1456,6 +1467,10 @@ impl RuntimeKeymap { keymap.global.find_transcript.as_ref(), app.find_transcript.as_slice(), ), + ( + keymap.global.select_transcript.as_ref(), + app.select_transcript.as_slice(), + ), ( keymap.global.focus_activity.as_ref(), app.focus_activity.as_slice(), @@ -1647,7 +1662,7 @@ impl RuntimeKeymap { } } resolved.configure_search(keymap)?; - chords::quick_dial_defaults::register(keymap, &mut resolved); + chords::leader_defaults::register(keymap, &mut resolved); resolved.validate_conflicts()?; chords::validate_chord_conflicts(&resolved)?; chords::install_dispatch_bindings(&mut resolved)?; @@ -1676,6 +1691,7 @@ impl RuntimeKeymap { open_agents: default_bindings![], open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], find_transcript: default_bindings![plain(KeyCode::F(3))], + select_transcript: default_bindings![], focus_activity: default_bindings![plain(KeyCode::F(4))], open_warnings: default_bindings![plain(KeyCode::F(2))], open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))], @@ -2052,6 +2068,7 @@ impl RuntimeKeymap { ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), + ("select_transcript", self.app.select_transcript.as_slice()), ("focus_activity", self.app.focus_activity.as_slice()), ("open_warnings", self.app.open_warnings.as_slice()), ( @@ -2159,6 +2176,7 @@ impl RuntimeKeymap { ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), + ("select_transcript", self.app.select_transcript.as_slice()), ("focus_activity", self.app.focus_activity.as_slice()), ("open_warnings", self.app.open_warnings.as_slice()), ( @@ -2218,6 +2236,7 @@ impl RuntimeKeymap { ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), + ("select_transcript", self.app.select_transcript.as_slice()), ("focus_activity", self.app.focus_activity.as_slice()), ("open_warnings", self.app.open_warnings.as_slice()), ( @@ -2336,6 +2355,30 @@ impl RuntimeKeymap { validate_unique("pager", context_bindings(KeymapContext::Pager))?; + validate_unique( + "transcript", + [ + ( + "global.select_transcript", + self.app.select_transcript.as_slice(), + ), + ("pager.scroll_up", self.pager.scroll_up.as_slice()), + ("pager.scroll_down", self.pager.scroll_down.as_slice()), + ("pager.page_up", self.pager.page_up.as_slice()), + ("pager.page_down", self.pager.page_down.as_slice()), + ("pager.half_page_up", self.pager.half_page_up.as_slice()), + ("pager.half_page_down", self.pager.half_page_down.as_slice()), + ("pager.jump_top", self.pager.jump_top.as_slice()), + ("pager.jump_bottom", self.pager.jump_bottom.as_slice()), + ("pager.close", self.pager.close.as_slice()), + ( + "pager.close_transcript", + self.pager.close_transcript.as_slice(), + ), + ("pager.find", self.pager.find.as_slice()), + ], + )?; + validate_no_reserved( "pager", context_bindings(KeymapContext::Pager), @@ -2352,6 +2395,10 @@ impl RuntimeKeymap { "global.find_transcript", self.app.find_transcript.as_slice(), ), + ( + "global.select_transcript", + self.app.select_transcript.as_slice(), + ), ]), )?; @@ -4140,9 +4187,14 @@ mod tests { assert_eq!( ( runtime.composer.history_search_previous, - runtime.chat.open_presets + runtime.chat.open_presets, + runtime.app.select_transcript ), - (vec![key_hint::ctrl(KeyCode::Char('r'))], Vec::new()) + ( + vec![key_hint::ctrl(KeyCode::Char('r'))], + Vec::new(), + Vec::new() + ) ); let defaults = RuntimeKeymap::defaults(); for leader in ["page-up", "page-down"] { @@ -4153,11 +4205,13 @@ mod tests { ( runtime.pager.page_up, runtime.pager.page_down, - runtime.chat.open_presets + runtime.chat.open_presets, + runtime.app.select_transcript ), ( defaults.pager.page_up.clone(), defaults.pager.page_down.clone(), + Vec::new(), Vec::new() ) ); diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index ac7447af53d0..cfb430b5ce91 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -114,9 +114,15 @@ impl KeymapActionId { (Chat, List) => self.action != "open_presets", (List, Chat) => other.action != "open_presets", - // Activity focus and Find remain available during list navigation. - (Global, List) => matches!(self.action, "focus_activity" | "find_transcript"), - (List, Global) => matches!(other.action, "focus_activity" | "find_transcript"), + // Activity focus, Find, and selection remain available during list navigation. + (Global, List) => matches!( + self.action, + "focus_activity" | "find_transcript" | "select_transcript" + ), + (List, Global) => matches!( + other.action, + "focus_activity" | "find_transcript" | "select_transcript" + ), _ => self.context.overlaps(other.context), } @@ -281,6 +287,7 @@ define_runtime_action_bindings! { open_agents, open_transcript, find_transcript, + select_transcript, focus_activity, open_warnings => runtime_only, open_external_editor, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 865dffcc7be0..40733cf61b4d 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -34,7 +34,7 @@ const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY + const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX; const MAX_LEADER_CHORD_BINDINGS: usize = 1024; -pub(super) mod quick_dial_defaults; +pub(super) mod leader_defaults; const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ ("cancel", ctrl(KeyCode::Char('c'))), ("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))), @@ -52,6 +52,7 @@ const WARNINGS_FOCUS: u32 = 1 << 16; const VOICE_TOGGLE: u32 = 1 << 17; const RAW_KEY_CAPTURE: u32 = 1 << 18; const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19; +const TRANSCRIPT_ACTIONS: u32 = 1 << 20; const TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -81,6 +82,15 @@ impl KeymapContextSet { ) } + /// Transcript overlays admit pager bindings plus the explicit selection and Find actions. + pub(crate) const fn transcript() -> Self { + Self( + context_bit(KeymapContext::Pager) + | context_bit(KeymapContext::Global) + | TRANSCRIPT_ACTIONS, + ) + } + /// Warning pages admit navigation plus their own toggle and copy. pub(crate) const fn warnings() -> Self { Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | WARNINGS_FOCUS) @@ -154,13 +164,19 @@ impl KeymapContextSet { self.contains(action.context) && (self.0 & ACTIVITY_FOCUS == 0 || action.context != KeymapContext::Global - || matches!(action.action, "focus_activity" | "find_transcript")) + || matches!( + action.action, + "focus_activity" | "find_transcript" | "select_transcript" + )) && (self.0 & TRANSCRIPT_CLOSE == 0 || action.context != KeymapContext::Pager || action.action == "close_transcript") && (self.0 & TRANSCRIPT_BROWSING == 0 || action.context != KeymapContext::Global - || action.action == "open_transcript") + || matches!(action.action, "open_transcript" | "select_transcript")) + && (self.0 & TRANSCRIPT_ACTIONS == 0 + || action.context != KeymapContext::Global + || matches!(action.action, "find_transcript" | "select_transcript")) && (!self.is_warnings() || action.context != KeymapContext::List || action.action != "accept") @@ -214,9 +230,17 @@ impl RuntimeChordBinding { self.spec.starts_with("leader ") } - /// Leader consumers also remain active beside pager actions on native transcript surfaces. + /// Transcript selection remains active beside pager actions on native transcript surfaces. fn overlaps(&self, action: KeymapActionId) -> bool { self.action.overlaps(action) + || (matches!( + (self.action.context, self.action.action), + (KeymapContext::Global, "select_transcript") + ) || matches!( + (action.context, action.action), + (KeymapContext::Global, "select_transcript") + )) && KeymapContextSet::transcript().contains_action(self.action) + && KeymapContextSet::transcript().contains_action(action) || self.is_leader() && [ KeymapContextSet::browsing(), @@ -644,7 +668,7 @@ fn dispatch_binding(action: KeymapActionId) -> Option { )) } -fn dispatch_event(action: KeymapActionId) -> Option { +pub(crate) fn dispatch_event(action: KeymapActionId) -> Option { let (code, modifiers) = dispatch_binding(action)?.parts(); Some(KeyEvent::new(code, modifiers)) } @@ -745,6 +769,19 @@ Unbind or remap the existing shortcut before using it as a chord prefix.", if previous.action != binding.action && (previous.overlaps(binding.action) || binding.overlaps(previous.action)) && previous.chord == binding.chord + && !matches!( + ( + (previous.action.context, previous.action.action), + (binding.action.context, binding.action.action), + ), + ( + (KeymapContext::Global, "find_transcript"), + (KeymapContext::Pager, "find") + ) | ( + (KeymapContext::Pager, "find"), + (KeymapContext::Global, "find_transcript") + ) + ) { return Err(format!( "Ambiguous `{}` = `{}`: the same chord is already assigned to `{}`. \ diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs new file mode 100644 index 000000000000..425f8ca271f1 --- /dev/null +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -0,0 +1,82 @@ +//! Install implicit preset and selection shortcuts only for free leader chords. +//! Explicit bindings, including an explicit unbind, always take priority. +//! Implicit chords fit within the shared leader expansion limit. + +use super::KeyChord; +use super::KeymapActionId; +use super::KeymapContext; +use super::MAX_LEADER_CHORD_BINDINGS; +use super::RuntimeChordBinding; +use super::RuntimeKeymap; +use super::normalize_chord_binding; +use super::runtime_action_bindings; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; + +pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) { + let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub( + resolved + .chords + .bindings + .iter() + .filter(|binding| binding.is_leader()) + .count(), + ); + for (configured, context, action, completion, spec) in [ + ( + keymap.global.select_transcript.as_ref(), + KeymapContext::Global, + "select_transcript", + '[', + "leader [", + ), + ( + keymap.chat.open_presets.as_ref(), + KeymapContext::Chat, + "open_presets", + 'p', + "leader p", + ), + ] { + if remaining == 0 { + break; + } + if configured.is_some() { + continue; + } + let action = KeymapActionId { context, action }; + for prefix in resolved.chords.leader.clone() { + let binding = RuntimeChordBinding { + action, + chord: KeyChord { + prefix, + completion: crate::key_hint::plain(KeyCode::Char(completion)), + }, + spec: spec.into(), + }; + if !resolved.chords.bindings.iter().any(|existing| { + binding.overlaps(existing.action) && existing.chord == binding.chord + }) && !runtime_action_bindings(resolved) + // Owned transcript navigation also runs beside the composer. + .filter(|single| { + binding.overlaps(single.id) || single.id.context == KeymapContext::Pager + }) + .any(|single| { + single + .bindings + .iter() + .any(|key| normalize_chord_binding(*key) == prefix) + }) + { + std::sync::Arc::make_mut(&mut resolved.chords) + .bindings + .push(binding); + remaining -= 1; + if remaining == 0 { + break; + } + } + } + } + resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords); +} diff --git a/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs b/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs deleted file mode 100644 index b1849f7ab18f..000000000000 --- a/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Install the implicit preset shortcut only for free leader chords. -//! Explicit bindings, including an explicit unbind, always take priority. -//! Implicit chords fit within the shared leader expansion limit. - -use super::KeyChord; -use super::KeymapActionId; -use super::KeymapContext; -use super::MAX_LEADER_CHORD_BINDINGS; -use super::RuntimeChordBinding; -use super::RuntimeKeymap; -use super::normalize_chord_binding; -use super::runtime_action_bindings; -use codex_config::types::TuiKeymap; -use crossterm::event::KeyCode; - -pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) { - let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub( - resolved - .chords - .bindings - .iter() - .filter(|binding| binding.is_leader()) - .count(), - ); - if keymap.chat.open_presets.is_some() || remaining == 0 { - return; - } - let action = KeymapActionId { - context: KeymapContext::Chat, - action: "open_presets", - }; - for prefix in resolved.chords.leader.clone() { - let binding = RuntimeChordBinding { - action, - chord: KeyChord { - prefix, - completion: crate::key_hint::plain(KeyCode::Char('p')), - }, - spec: "leader p".into(), - }; - if !resolved - .chords - .bindings - .iter() - .any(|existing| binding.overlaps(existing.action) && existing.chord == binding.chord) - && !runtime_action_bindings(resolved) - // Owned transcript navigation also runs beside the composer. - .filter(|single| { - binding.overlaps(single.id) || single.id.context == KeymapContext::Pager - }) - .any(|single| { - single - .bindings - .iter() - .any(|key| normalize_chord_binding(*key) == prefix) - }) - { - std::sync::Arc::make_mut(&mut resolved.chords) - .bindings - .push(binding); - remaining -= 1; - if remaining == 0 { - break; - } - } - } - resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords); -} diff --git a/codex-rs/tui/src/keymap/select_transcript_tests.rs b/codex-rs/tui/src/keymap/select_transcript_tests.rs new file mode 100644 index 000000000000..2975beae09fb --- /dev/null +++ b/codex-rs/tui/src/keymap/select_transcript_tests.rs @@ -0,0 +1,99 @@ +use super::*; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; + +fn binding(spec: &str) -> KeybindingsSpec { + KeybindingsSpec::One(KeybindingSpec(spec.to_string())) +} + +fn dispatch_select( + runtime: &RuntimeKeymap, + prefix: crossterm::event::KeyEvent, + contexts: KeymapContextSet, +) -> bool { + let mut matcher = KeyChordMatcher::default(); + assert!(matches!( + matcher.advance(prefix, &runtime.chords, contexts), + KeyChordMatch::Pending(_) + )); + let KeyChordMatch::Completed(key) = + matcher.advance(KeyCode::Char('[').into(), &runtime.chords, contexts) + else { + return false; + }; + runtime.app.select_transcript.is_pressed(key) +} + +#[test] +fn select_transcript_uses_the_shared_leader_in_main_activity_and_overlay_contexts() { + let runtime = RuntimeKeymap::defaults(); + for contexts in [ + KeymapContextSet::new(KeymapContext::Global), + KeymapContextSet::activity(), + KeymapContextSet::transcript(), + ] { + assert!(dispatch_select( + &runtime, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + contexts + )); + } +} + +#[test] +fn select_transcript_can_be_remapped_or_unbound() { + let mut config = TuiKeymap::default(); + config.global.leader = Some(binding("f6")); + let runtime = RuntimeKeymap::from_config(&config).expect("remapped leader"); + assert!(dispatch_select( + &runtime, + KeyCode::F(6).into(), + KeymapContextSet::new(KeymapContext::Global) + )); + + config.global.select_transcript = Some(KeybindingsSpec::Many(Vec::new())); + let runtime = RuntimeKeymap::from_config(&config).expect("unbound selection action"); + assert!(runtime.app.select_transcript.is_empty()); + assert!( + runtime + .chords + .bindings + .iter() + .all(|binding| binding.action.action != "select_transcript") + ); +} + +#[test] +fn default_select_suffix_yields_but_explicit_conflicts_fail() { + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(binding("leader [")); + let runtime = RuntimeKeymap::from_config(&config).expect("existing leader consumer wins"); + assert!( + runtime + .chords + .bindings + .iter() + .all(|binding| binding.action.action != "select_transcript") + ); + + config.global.open_transcript = None; + config.pager.scroll_up = Some(binding("ctrl-x")); + let runtime = RuntimeKeymap::from_config(&config).expect("leader prefix consumer wins"); + assert!( + runtime + .chords + .bindings + .iter() + .all(|binding| binding.action.action != "select_transcript") + ); + + config.pager.scroll_up = None; + config.global.open_transcript = Some(binding("leader [")); + config.global.select_transcript = Some(binding("leader [")); + let error = RuntimeKeymap::from_config(&config).expect_err("explicit conflict"); + assert!(error.contains("select_transcript") && error.contains("open_transcript")); +} diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index 44097d451540..62e27083e545 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -26,6 +26,10 @@ pub(crate) struct TranscriptOverlay { notice: Option, pub(crate) key_chord_hint: Option>, pub(crate) browsing_footer: Option>, + find_transcript: Vec, + select_transcript: Vec, + pub(super) pending_follow_close: Option<(u64, bool)>, + pending_select_mode: bool, is_done: bool, } @@ -63,6 +67,10 @@ impl TranscriptOverlay { notice: None, key_chord_hint: None, browsing_footer: None, + find_transcript: Vec::new(), + select_transcript: Vec::new(), + pending_follow_close: None, + pending_select_mode: false, is_done: false, } } @@ -81,6 +89,14 @@ impl TranscriptOverlay { content_height, ); self.view.render(self.content_area, buf, &self.cells); + if self.pending_select_mode { + self.pending_select_mode = false; + if self.view.begin_select_mode(&self.cells) { + self.view.render(self.content_area, buf, &self.cells); + } else { + self.is_done = true; + } + } if let Some(index) = self.pending_highlight.take() { self.view.ensure_entry_visible(&self.cells, index); self.view.render(self.content_area, buf, &self.cells); @@ -175,26 +191,15 @@ impl TranscriptOverlay { } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - if let Some(completion) = tui.clipboard.poll() - && let Some(follow) = - self.view - .finish_copy(&self.cells, completion, /*current*/ true) - { - self.notice = Some(match &completion.1 { - Ok(status) => status.message("selection"), - Err(error) => format!("Copy failed: {error}"), - }); - if follow { - self.view.jump_to_latest(); - self.is_done = self.browsing_footer.is_some(); - } + if let Some(completion) = tui.clipboard.poll() { + self.finish_pending_copy(completion); } if matches!(event, TuiEvent::Resume | TuiEvent::FocusLost) { self.view.cancel_primary(); self.view.end_drag(); } // Apply a queued prompt jump before navigation can move away from it. - if self.pending_highlight.is_some() + if (self.pending_select_mode || self.pending_highlight.is_some()) && (matches!(&event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release) || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != MouseEventKind::Moved)) { @@ -213,6 +218,9 @@ impl TranscriptOverlay { self.view.handle_mouse(mouse, &self.cells) } TuiEvent::Paste(text) => { + if self.view.is_select_mode() && !self.view.is_search_editing() { + return Ok(()); + } if self.view.is_search_editing() { self.view.end_selection(&self.cells); } @@ -244,6 +252,9 @@ impl TranscriptOverlay { } pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.find_transcript.clone_from(&keymap.app.find_transcript); + self.select_transcript + .clone_from(&keymap.app.select_transcript); self.view.set_keymap_bindings(keymap); } @@ -251,6 +262,10 @@ impl TranscriptOverlay { self.view.begin_search(); } + pub(crate) fn begin_select_mode(&mut self) { + self.pending_select_mode = !self.view.begin_select_mode(&self.cells); + } + pub(crate) fn is_search_editing(&self) -> bool { self.view.is_search_editing() } @@ -279,7 +294,11 @@ impl TranscriptOverlay { if previous == TranscriptHistoryState::LoadingBeginning && state == TranscriptHistoryState::Complete { - self.view.jump_to_entry(&self.cells, /*index*/ 0); + if self.view.is_select_mode() { + self.view.complete_select_beginning(&self.cells); + } else { + self.view.jump_to_entry(&self.cells, /*index*/ 0); + } } previous } @@ -421,12 +440,40 @@ impl TranscriptOverlay { return None; } if !self.view.is_search_editing() + && !self.view.is_select_mode() && (!self.view.has_active_interaction() || !self.view.owns_interaction_key(key)) && self.keymap.find.is_pressed(key) { self.view.begin_search(); return Some(ViewAction::Changed); } + if !self.view.is_search_editing() + && !self.view.is_select_mode() + && self.find_transcript.is_pressed(key) + { + self.view.begin_search(); + return Some(ViewAction::Changed); + } + if !self.view.is_search_editing() + && !self.view.is_select_mode() + && self.select_transcript.is_pressed(key) + { + self.begin_select_mode(); + return Some(ViewAction::Changed); + } + if self.view.is_select_mode() && !self.view.is_search_editing() { + if let Some(action) = self.view.handle_select_mode_key(key, &self.cells) { + return Some(action); + } + if self.keymap.find.is_pressed(key) || self.find_transcript.is_pressed(key) { + self.view.begin_search(); + return Some(ViewAction::Changed); + } + if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key) { + self.is_done = true; + } + return Some(ViewAction::Changed); + } if self.view.has_active_interaction() && let Some(action) = self.view.handle_key(key, &self.cells) { @@ -446,14 +493,41 @@ impl TranscriptOverlay { self.view.cancel_beginning(); } + fn finish_select_follow(&mut self, close_overlay: bool) { + self.view.jump_to_latest(); + if close_overlay { + self.is_done = true; + } + } + fn apply_action(&mut self, tui: &mut tui::Tui, action: ViewAction) { self.notice = None; - if let Some((_, result)) = self.view.copy_action( + let resume_following = matches!(action, ViewAction::CopyAndFollow(_)); + let close_overlay = self.view.is_select_mode() || self.browsing_footer.is_some(); + if matches!(action, ViewAction::ExitSelectMode) { + self.is_done = true; + } else if let Some((_, result)) = self.view.copy_action( tui, &self.cells, &action, crate::clipboard_copy::CopyFormat::Markdown, ) { + if resume_following { + match result { + Ok(crate::clipboard_copy::CopyStatus::Confirmed) => { + self.pending_follow_close = None; + self.finish_select_follow(close_overlay); + } + Ok(crate::clipboard_copy::CopyStatus::Pending(id)) => { + self.pending_follow_close = Some((id, close_overlay)); + } + Ok( + crate::clipboard_copy::CopyStatus::Unconfirmed + | crate::clipboard_copy::CopyStatus::Busy, + ) + | Err(_) => {} + } + } self.notice = Some(match result { Ok(status) => status.message("selection"), Err(error) => error, @@ -465,6 +539,35 @@ impl TranscriptOverlay { } } + pub(super) fn finish_pending_copy( + &mut self, + completion: &(u64, crate::clipboard_copy::worker::CopyResult), + ) { + let close_overlay = match self.pending_follow_close { + Some((id, close_overlay)) if id == completion.0 => { + self.pending_follow_close = None; + Some(close_overlay) + } + Some((id, _)) if id < completion.0 => { + self.pending_follow_close = None; + None + } + _ => None, + }; + if let Some(follow) = self + .view + .finish_copy(&self.cells, completion, /*current*/ true) + { + self.notice = Some(match &completion.1 { + Ok(status) => status.message("selection"), + Err(error) => format!("Copy failed: {error}"), + }); + if follow { + self.finish_select_follow(close_overlay.unwrap_or(false)); + } + } + } + fn render_hints(&self, area: Rect, buf: &mut Buffer) { let first = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area); render_navigation_hints(first, buf, &self.keymap); diff --git a/codex-rs/tui/src/pager_overlay/transcript_tests.rs b/codex-rs/tui/src/pager_overlay/transcript_tests.rs index 4dd388f9cd66..1853aff3abd5 100644 --- a/codex-rs/tui/src/pager_overlay/transcript_tests.rs +++ b/codex-rs/tui/src/pager_overlay/transcript_tests.rs @@ -28,6 +28,87 @@ fn transcript_overlay(cells: Vec>) -> TranscriptOverlay { TranscriptOverlay::new(cells, default_pager_keymap()) } +#[test] +fn empty_pending_select_mode_closes_instead_of_retaining_overlay_focus() { + let mut overlay = transcript_overlay(Vec::new()); + overlay.begin_select_mode(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8, + ); + + overlay.render(area, &mut Buffer::empty(area)); + + assert!(overlay.is_done()); +} + +#[tokio::test] +async fn select_enter_closes_and_remapped_action_marks_in_the_inline_overlay() -> Result<()> { + let cells = || { + vec![Arc::new(TestCell { + lines: vec!["copy me".into()], + }) as Arc] + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8, + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + + let mut remapped = transcript_overlay(cells()); + let config = serde_json::from_value(serde_json::json!({ + "global": {"find_transcript": "v"} + }))?; + let keymap = + crate::keymap::RuntimeKeymap::from_config(&config).expect("configured select shortcut"); + remapped.set_keymap_bindings(&keymap); + remapped.render(area, &mut Buffer::empty(area)); + remapped.begin_select_mode(); + remapped.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('v').into()))?; + assert_eq!( + remapped.view.selected_text(&remapped.cells).as_deref(), + Some("c") + ); + + let mut find = transcript_overlay(cells()); + find.set_keymap_bindings(&keymap); + find.render(area, &mut Buffer::empty(area)); + find.begin_search(); + find.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('v').into()))?; + assert!(find.view.is_search_editing()); + assert!(!find.view.is_select_mode()); + + let mut enter = transcript_overlay(cells()); + let mut pager_find = keymap.clone(); + pager_find.pager.find = vec![crate::key_hint::KeyBinding::from_event( + KeyCode::Enter.into(), + )]; + enter.set_keymap_bindings(&pager_find); + enter.render(area, &mut Buffer::empty(area)); + enter.begin_select_mode(); + enter.handle_event(&mut tui, TuiEvent::Key(KeyCode::Enter.into()))?; + assert!(enter.is_done()); + + let mut ordinary = transcript_overlay(vec![Arc::new(history_cell::AgentMarkdownCell::new( + "copy me".to_string(), + std::path::Path::new("/"), + ))]); + ordinary.render(area, &mut Buffer::empty(area)); + ordinary.browsing_footer = Some("browsing prompts".into()); + assert!( + ordinary + .view + .begin_copy_mode(&ordinary.cells, /*guard*/ None) + ); + ordinary.handle_event(&mut tui, TuiEvent::Key(KeyCode::Enter.into()))?; + if let Some((id, close_overlay)) = ordinary.pending_follow_close { + assert!(close_overlay); + ordinary.browsing_footer = None; + ordinary.finish_pending_copy(&(id, Ok(crate::clipboard_copy::CopyStatus::Confirmed))); + } + assert!(ordinary.is_done()); + + Ok(()) +} + #[test] fn scrolling_prepend_and_resize_preserve_reading_content() { let cells = (0..30) diff --git a/codex-rs/tui/src/transcript_view/footer.rs b/codex-rs/tui/src/transcript_view/footer.rs index 191e339ecc6e..addb462eb1cf 100644 --- a/codex-rs/tui/src/transcript_view/footer.rs +++ b/codex-rs/tui/src/transcript_view/footer.rs @@ -48,7 +48,40 @@ impl TranscriptView { }, ] .into(), - cursor_column: self.selection.is_none().then_some(cursor), + cursor_column: Some(cursor), + is_interactive: true, + }); + } + if self.select_mode.is_some() { + let hints = if has_selected_text { + let copy = crate::key_hint::ctrl(KeyCode::Char('c')).display_label(); + [ + format!( + "arrows/hjkl/w/b/e/shift+arrows move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V mark · o swap · / find · y/{copy} copy · enter copy & latest · esc clear · q close" + ), + format!( + "arrows/hjkl move · o swap · y/{copy} copy · enter copy & latest · esc clear · q close" + ), + format!("y/{copy} copy · enter copy & latest · esc clear · q close"), + "enter copy · q close".to_owned(), + ] + } else { + [ + "arrows/hjkl/w/b/e move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V/shift+arrows mark · / find · n/N next · enter/q/esc close" + .to_owned(), + "arrows/hjkl/w/b/e move · 0/$/g/G ends · v/space/V mark · / find · enter/q/esc close" + .to_owned(), + "arrows/hjkl move · v/space/V mark · / find · enter/q/esc close".to_owned(), + "v mark · enter/q close".to_owned(), + ] + }; + return Some(TranscriptFooter { + text: first_fitting_line( + hints.iter().map(String::as_str).map(navigation_line), + width, + ) + .into(), + cursor_column: None, is_interactive: true, }); } @@ -238,8 +271,15 @@ pub(super) fn navigation_line(navigation: &str) -> Line<'static> { } let action = [ " copy & follow", + " copy & latest", " clear selection", " copy+↓", + " move", + " ends", + " page", + " mark", + " find", + " swap", " previous", " accept", " cancel", diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index c52b388d9ade..1ce29ba3afe7 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -262,7 +262,25 @@ impl TranscriptView { event: MouseEvent, cells: &[Arc], ) -> Option { - self.handle_mouse_with_mode(event, cells, MouseMode::Selection) + let endpoint_changed = matches!( + event.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::Drag(MouseButton::Left) + | MouseEventKind::Up(MouseButton::Left) + ); + let before = self + .selection + .as_ref() + .map(|selection| (selection.start, selection.end, selection.unit())); + let action = self.handle_mouse_with_mode(event, cells, MouseMode::Selection); + let after = self + .selection + .as_ref() + .map(|selection| (selection.start, selection.end, selection.unit())); + if endpoint_changed && before != after { + self.adopt_select_mouse_range(); + } + action } fn handle_mouse_with_mode( diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs index 5a2077f7ae22..1cdc1df87884 100644 --- a/codex-rs/tui/src/transcript_view/select_mode.rs +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -92,7 +92,6 @@ impl SelectMode { impl TranscriptView { /// Enter at the accepted Find hit, preferring the bottommost visible selectable position. - #[cfg_attr(not(test), expect(dead_code, reason = "used by a later stack layer"))] pub(crate) fn begin_select_mode(&mut self, cells: &[Arc]) -> bool { if self.select_mode.is_some() { return true; @@ -136,6 +135,17 @@ impl TranscriptView { true } + pub(crate) fn is_select_mode(&self) -> bool { + self.select_mode.is_some() + } + + /// Finish an explicit beginning traversal after the last older page is installed. + pub(crate) fn complete_select_beginning(&mut self, cells: &[Arc]) { + if self.select_mode.is_some() { + self.move_select_cursor(cells, CursorMotion::Beginning); + } + } + /// Leave the mode while retaining its displayed revision at the paused reading position. pub(crate) fn end_select_mode(&mut self) { if self.select_mode.take().is_none() { diff --git a/codex-rs/tui/src/transcript_view/select_mode_tests.rs b/codex-rs/tui/src/transcript_view/select_mode_tests.rs index 1694572aa282..457a2a2a7d0a 100644 --- a/codex-rs/tui/src/transcript_view/select_mode_tests.rs +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -1,8 +1,13 @@ use super::*; use crate::clipboard_copy::CopyStatus; use crate::history_cell::PlainHistoryCell; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; fn cell(lines: &[&str]) -> Arc { Arc::new(PlainHistoryCell::new( @@ -608,3 +613,194 @@ fn accepted_find_pins_an_expanded_match_before_select_mode_exits() { expanded ); } +#[test] +fn beginning_motion_finishes_at_the_first_grapheme_after_paging() { + let mut cells = vec![cell(&["newer"])]; + let mut view = TranscriptView { + history: TranscriptHistoryState::Partial, + ..Default::default() + }; + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + assert_eq!(view.history, TranscriptHistoryState::LoadingBeginning); + + cells.insert(/*index*/ 0, cell(&["older"])); + view.history_loaded(&cells, 0..1); + view.history = TranscriptHistoryState::Complete; + view.complete_select_beginning(&cells); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("o")); +} + +#[test] +fn shift_click_extends_from_the_keyboard_marks_fixed_anchor() { + let cells = vec![cell(&["abcdef"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + press(&mut view, &cells, KeyCode::Right); + press(&mut view, &cells, KeyCode::Right); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Right); + press(&mut view, &cells, KeyCode::Right); + assert_eq!(view.selected_text(&cells).as_deref(), Some("cde")); + + view.handle_mouse( + MouseEvent { + kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: 0, + row: 0, + modifiers: KeyModifiers::SHIFT, + }, + &cells, + ); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("abc")); +} + +#[test] +fn existing_mouse_selection_keeps_its_word_or_line_unit() { + let word_cells = vec![cell(&["alpha beta"])]; + let mut word_view = TranscriptView::default(); + render(&mut word_view, &word_cells); + word_view.begin_selection( + &word_cells, + /*column*/ 1, + /*row*/ 0, + /*clicks*/ 2, + ); + assert!(word_view.begin_select_mode(&word_cells)); + press(&mut word_view, &word_cells, KeyCode::Char('h')); + assert_eq!( + word_view.selected_text(&word_cells).as_deref(), + Some(&"alpha"[..4]) + ); + + let line_cells = vec![cell(&["first", "second"])]; + let mut line_view = TranscriptView::default(); + render(&mut line_view, &line_cells); + line_view.begin_selection( + &line_cells, + /*column*/ 1, + /*row*/ 1, + /*clicks*/ 3, + ); + assert!(line_view.begin_select_mode(&line_cells)); + press(&mut line_view, &line_cells, KeyCode::Char('k')); + assert_eq!( + line_view.selected_text(&line_cells).as_deref(), + Some("first\nsecond") + ); +} + +#[test] +fn passive_mouse_events_do_not_reanchor_a_reverse_line_mark() { + let cells = vec![cell(&["first", "second", "third"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + press(&mut view, &cells, KeyCode::Char('j')); + press(&mut view, &cells, KeyCode::Char('V')); + press(&mut view, &cells, KeyCode::Char('k')); + + for kind in [MouseEventKind::Moved, MouseEventKind::ScrollDown] { + view.handle_mouse( + MouseEvent { + kind, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }, + &cells, + ); + } + view.handle_mouse( + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 80, + row: 80, + modifiers: KeyModifiers::NONE, + }, + &cells, + ); + press(&mut view, &cells, KeyCode::Char('j')); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("second\n")); +} + +#[test] +fn select_mode_renders_cursor_and_scannable_footer_hints() { + let cells = vec![cell(&["alpha beta", "gamma"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + let buffer = render(&mut view, &cells); + let [unmarked_wide, footer, unmarked_narrow] = [240, 64, 32].map(|width| { + view.footer_with_navigation( + width, + crate::motion::MotionMode::Reduced, + /*latest_navigation*/ "", + ) + .expect("select footer") + }); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 64, + footer.text.height() as u16, + ); + let mut footer_buffer = Buffer::empty(area); + Paragraph::new(footer.text).render(area, &mut footer_buffer); + + press(&mut view, &cells, KeyCode::Char('g')); + press(&mut view, &cells, KeyCode::Char('v')); + for _ in 0..5 { + press(&mut view, &cells, KeyCode::Right); + } + let marked_buffer = render(&mut view, &cells); + let [marked_wide, marked] = [240, 64].map(|width| { + view.footer_with_navigation( + width, + crate::motion::MotionMode::Reduced, + /*latest_navigation*/ "", + ) + .expect("select footer") + }); + + press(&mut view, &cells, KeyCode::Esc); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("beta")); + while view.advance_search(&cells) {} + let find_buffer = render(&mut view, &cells); + let find_footer = view + .footer_with_navigation( + /*width*/ 64, + crate::motion::MotionMode::Reduced, + /*latest_navigation*/ "", + ) + .expect("find footer"); + let find_footer_text = find_footer + .text + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + press(&mut view, &cells, KeyCode::Enter); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Right); + let find_marked_buffer = render(&mut view, &cells); + + insta::assert_snapshot!(format!( + "{buffer:?}\nunmarked wide: {}\nunmarked:\n{footer_buffer:?}\nunmarked narrow: {}\nmarked:\n{marked_buffer:?}\nmarked wide: {}\nmarked: {}\nfind:\n{find_buffer:?}\nfind footer: {}\nfind cursor: {:?}\nfind marked:\n{find_marked_buffer:?}", + unmarked_wide.text, + unmarked_narrow.text, + marked_wide.text, + marked.text, + find_footer_text, + find_footer.cursor_column, + )); +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__select_mode_renders_cursor_and_scannable_footer_hints.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__select_mode_renders_cursor_and_scannable_footer_hints.snap new file mode 100644 index 000000000000..2c0e63cbf15f --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__select_mode_renders_cursor_and_scannable_footer_hints.snap @@ -0,0 +1,99 @@ +--- +source: tui/src/transcript_view/select_mode_tests.rs +assertion_line: 627 +expression: "format!(\"{buffer:?}\\nunmarked wide: {}\\nunmarked:\\n{footer_buffer:?}\\nunmarked narrow: {}\\nmarked:\\n{marked_buffer:?}\\nmarked wide: {}\\nmarked: {}\\nfind:\\n{find_buffer:?}\\nfind footer: {}\\nfind cursor: {:?}\\nfind marked:\\n{find_marked_buffer:?}\",\nunmarked_wide.text, unmarked_narrow.text, marked_wide.text, marked.text,\nfind_footer.text, find_footer.cursor_column,)" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 6 }, + content: [ + "alpha beta ", + "gamma ", + " ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED, + x: 1, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} +unmarked wide: arrows/hjkl/w/b/e move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V/shift+arrows mark · / find · n/N next · enter/q/esc close +unmarked: +Buffer { + area: Rect { x: 0, y: 0, width: 64, height: 1 }, + content: [ + "arrows/hjkl move · v/space/V mark · / find · enter/q/esc close ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 19, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 28, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 37, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 56, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 62, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} +unmarked narrow: v mark · enter/q close +marked: +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 6 }, + content: [ + "alpha beta ", + "gamma ", + " ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, + x: 5, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED, + x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} +marked wide: arrows/hjkl/w/b/e/shift+arrows move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V mark · o swap · / find · y/⌃c copy · enter copy & latest · esc clear · q close +marked: y/⌃c copy · enter copy & latest · esc clear · q close +find: +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 6 }, + content: [ + "alpha beta ", + "gamma ", + " ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 5, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED, + x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, + x: 10, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} +find footer: Find: beta +enter accept · ⌃p older · ⌃n newer · esc cancel +find cursor: Some(10) +find marked: +Buffer { + area: Rect { x: 0, y: 0, width: 32, height: 6 }, + content: [ + "alpha beta ", + "gamma ", + " ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, + x: 7, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED, + x: 8, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} From 964aeda6273f67f633a2f0f198158471ef095547 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 25 Oct 2026 05:00:45 +0000 Subject: [PATCH 046/287] Fix transcript selection input routing and preserve composer drafts (#57224) ## Why Leaving transcript selection with a held key can send repeated input to the composer and submit a preserved draft. ## What changed - Route the transcript selection shortcut and selection controls in both the owned transcript and transcript overlay, preserving drafts and ignoring pastes outside search. - Give fixed selection controls and pager motions precedence over conflicting key chords while allowing search and voice controls. - Consume held exit keys across selection dismissal and asynchronous copy completion, and require fresh presses for composer submit and queue actions. - Initialize overlay selection against the refreshed transcript frame and complete selection navigation when older history finishes loading. ## Testing Add regression coverage for draft preservation, repeated `Enter` events, selection and search key precedence, refreshed-frame selection, and pending copy exit-key cleanup. GitOrigin-RevId: 6cca9f399af1d49ff79337ae745cfc1564a14b88 --- codex-rs/tui/src/app.rs | 24 ++- codex-rs/tui/src/app/clipboard.rs | 7 + codex-rs/tui/src/app/history_pagination.rs | 9 +- codex-rs/tui/src/app/input.rs | 28 +++- codex-rs/tui/src/app/input/select_mode.rs | 146 ++++++++++++++++++ codex-rs/tui/src/app/owned_transcript.rs | 75 +++++++-- .../tui/src/app/owned_transcript_tests.rs | 132 ++++++++++++++++ codex-rs/tui/src/app/tests/key_chords.rs | 80 +++++++++- codex-rs/tui/src/app_backtrack.rs | 88 ++++++++++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 7 + codex-rs/tui/src/keymap.rs | 2 + codex-rs/tui/src/keymap/chords.rs | 4 +- codex-rs/tui/src/pager_overlay.rs | 2 +- codex-rs/tui/src/pager_overlay/transcript.rs | 35 +++-- codex-rs/tui/src/tui.rs | 4 + codex-rs/tui/src/tui/alternate_screen.rs | 8 + codex-rs/tui/src/tui/keyboard_modes.rs | 40 +++-- 17 files changed, 649 insertions(+), 42 deletions(-) create mode 100644 codex-rs/tui/src/app/input/select_mode.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f3326b44bb78..d2bb38e890cc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -887,6 +887,23 @@ impl App { self.chat_widget.acknowledge_program_status(); } let idle_draw = matches!(event, TuiEvent::Draw); + if let TuiEvent::Key(key) = &event { + self.backtrack.observe_pending_select_exit_key(*key); + } + if let TuiEvent::Key(key) = &event + && let Some(binding) = self.backtrack.select_exit_key + { + if binding.parts() == crate::key_hint::KeyBinding::from_event(*key).parts() { + if key.kind == KeyEventKind::Release + || !self.enhanced_keys_supported + || !crate::tui::reports_key_releases() + { + self.backtrack.select_exit_key = None; + } + return Ok(AppRunControl::Continue); + } + self.backtrack.select_exit_key = None; + } if self.handle_rendered_selection_event(tui, &event)? { return Ok(AppRunControl::Continue); } @@ -993,9 +1010,12 @@ impl App { }) && app.keymap.chat.toggle_voice.is_pressed(key) }; - // Find consumes otherwise-unhandled keys; let enabled voice controls reach App. + // Transcript interactions consume otherwise-unhandled keys; let enabled voice controls + // reach App. if !matches!(&event, TuiEvent::Key(key) - if voice_toggle(self, *key) && !self.transcript_view.owns_interaction_key(*key)) + if voice_toggle(self, *key) + && (!self.transcript_view.owns_interaction_key(*key) + || self.transcript_view.is_select_mode())) && self.handle_owned_transcript_event(tui, app_server, &event)? { return Ok(AppRunControl::Continue); diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs index 5b25a0f2998c..40b882ed18d3 100644 --- a/codex-rs/tui/src/app/clipboard.rs +++ b/codex-rs/tui/src/app/clipboard.rs @@ -69,11 +69,18 @@ impl App { let follow = self.transcript_view .finish_copy(&self.transcript_cells, completion, current); + let pending_exit_key = if current { + self.backtrack + .finish_pending_select_exit_key(completion.0, /*select_mode_closed*/ true) + } else { + None + }; if follow == Some(true) { if self.backtrack.overlay_preview_active { self.close_transcript_overlay(tui); } self.transcript_view.jump_to_latest(); + self.backtrack.select_exit_key = pending_exit_key; } } if !current || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) { diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index 0c9f0053fbb4..f7210795b22a 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -287,8 +287,13 @@ impl App { }; let continue_to_start = previous == TranscriptHistoryState::LoadingBeginning; if continue_to_start && !self.scrollback_has_older_history { - self.transcript_view - .jump_to_entry(&self.transcript_cells, /*index*/ 0); + if self.transcript_view.is_select_mode() { + self.transcript_view + .complete_select_beginning(&self.transcript_cells); + } else { + self.transcript_view + .jump_to_entry(&self.transcript_cells, /*index*/ 0); + } } if self.scrollback_has_older_history && (continue_to_start diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 2ceb510c23df..d2be9ee5d132 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -9,6 +9,8 @@ use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE; use crate::keymap::bindings_for_action; use crate::keymap::keymap_action_ids; +mod select_mode; + impl App { pub(super) fn should_recover_vim_insert_escape(&self, key_event: KeyEvent) -> bool { let active_contexts = self.active_keymap_contexts(); @@ -90,14 +92,16 @@ impl App { if self.overlay.is_none() && self.chat_widget.handle_quick_dial_menu(key_event) { return None; } - if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event)) + let select_mode_chord = self.select_mode_owns_chord(tui, key_event); + if (matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event)) || (tui.is_owned_screen() && self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() && self.transcript_view.owns_interaction_key(key_event) && (self.transcript_view.has_active_interaction() || self.backtrack.overlay_preview_active - || crate::transcript_view::JumpTarget::from_key(key_event).is_none())) + || crate::transcript_view::JumpTarget::from_key(key_event).is_none()))) + && !select_mode_chord { let close_chord = tui.is_owned_screen() && self.overlay.is_none() @@ -232,7 +236,13 @@ impl App { } else { KeymapContext::Pager }; - let contexts = KeymapContextSet::new(context).with_voice_toggle(&self.keymap); + let contexts = + if matches!(overlay, Overlay::Transcript(_)) && context == KeymapContext::Pager { + KeymapContextSet::transcript() + } else { + KeymapContextSet::new(context) + } + .with_voice_toggle(&self.keymap); return if self.backtrack.overlay_preview_active && context == KeymapContext::Pager { KeymapContextSet::browsing().with_voice_toggle(&self.keymap) } else { @@ -242,6 +252,9 @@ impl App { if self.transcript_view.is_search_editing() && self.chat_widget.no_modal_or_popup_active() { return KeymapContextSet::new(KeymapContext::Editor).with_voice_toggle(&self.keymap); } + if self.transcript_view.is_select_mode() && self.chat_widget.no_modal_or_popup_active() { + return KeymapContextSet::transcript().with_voice_toggle(&self.keymap); + } if self.transcript_view.is_activity_focused() && self.chat_widget.no_modal_or_popup_active() { return KeymapContextSet::activity().with_voice_toggle(&self.keymap); @@ -583,8 +596,11 @@ impl App { } let find_transcript = self.keymap.app.find_transcript.is_pressed(key_event); + let select_transcript = self.keymap.app.select_transcript.is_pressed(key_event); if app_keymap_shortcuts_available - && (self.keymap.app.open_transcript.is_pressed(key_event) || find_transcript) + && (self.keymap.app.open_transcript.is_pressed(key_event) + || find_transcript + || select_transcript) { self.scrollback_has_older_history = self .chat_widget @@ -595,6 +611,10 @@ impl App { overlay.set_keymap_bindings(&self.keymap); overlay.begin_search(); } + if select_transcript && let Some(Overlay::Transcript(overlay)) = &mut self.overlay { + overlay.set_keymap_bindings(&self.keymap); + overlay.begin_select_mode(); + } return; } diff --git a/codex-rs/tui/src/app/input/select_mode.rs b/codex-rs/tui/src/app/input/select_mode.rs new file mode 100644 index 000000000000..1664dd2b3be1 --- /dev/null +++ b/codex-rs/tui/src/app/input/select_mode.rs @@ -0,0 +1,146 @@ +//! Transcript selection chord routing kept outside the central application input path. + +use super::super::*; + +impl App { + pub(crate) fn transcript_select_exit_key(event: &TuiEvent) -> Option { + let TuiEvent::Key(key) = event else { + return None; + }; + let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers); + (key.kind == KeyEventKind::Press + && modifiers == KeyModifiers::NONE + && matches!(code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q'))) + .then(|| KeyBinding::from_event(*key)) + } + + pub(super) fn select_mode_owns_chord(&self, tui: &tui::Tui, key_event: KeyEvent) -> bool { + let select_mode = matches!( + &self.overlay, + Some(Overlay::Transcript(overlay)) if overlay.is_select_mode() + ) || (tui.is_owned_screen() + && self.overlay.is_none() + && self.transcript_view.is_select_mode()); + if !select_mode { + return false; + } + let contexts = self.active_keymap_contexts(); + let search_editing = matches!( + &self.overlay, + Some(Overlay::Transcript(overlay)) if overlay.is_search_editing() + ) || (self.overlay.is_none() + && self.transcript_view.is_search_editing()); + let (code, modifiers) = + crate::key_hint::normalize_key_parts(key_event.code, key_event.modifiers); + let fixed_search_control = search_editing + && (code == KeyCode::Esc + || (modifiers == KeyModifiers::NONE + && matches!(code, KeyCode::Enter | KeyCode::PageUp | KeyCode::PageDown)) + || (modifiers == KeyModifiers::CONTROL + && matches!(code, KeyCode::Char('c' | 'n' | 'p')))); + let fixed_select_control = !search_editing + && key_event.kind != KeyEventKind::Release + && (crate::text_selection::is_copy_key(key_event) + || matches!( + (code, modifiers), + ( + KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q' | '/' | 'o' | 'y'), + KeyModifiers::NONE + ) | ( + KeyCode::Char(' '), + KeyModifiers::NONE | KeyModifiers::CONTROL + ) | ( + KeyCode::Char('v' | 'n' | 'g' | '$'), + KeyModifiers::NONE | KeyModifiers::SHIFT + ) | ( + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down, + KeyModifiers::NONE | KeyModifiers::SHIFT + ) | ( + KeyCode::Char('h' | 'l' | 'k' | 'j' | 'w' | 'b' | 'e' | '0'), + KeyModifiers::NONE + ) | ( + KeyCode::Home | KeyCode::End, + KeyModifiers::NONE | KeyModifiers::CONTROL + ) | (KeyCode::PageUp | KeyCode::PageDown, KeyModifiers::NONE) + | (KeyCode::Char('b' | 'f' | 'u' | 'd'), KeyModifiers::CONTROL) + )); + let pager_motion = !search_editing + && [ + &self.keymap.pager.scroll_up, + &self.keymap.pager.scroll_down, + &self.keymap.pager.page_up, + &self.keymap.pager.page_down, + &self.keymap.pager.half_page_up, + &self.keymap.pager.half_page_down, + &self.keymap.pager.jump_top, + &self.keymap.pager.jump_bottom, + ] + .into_iter() + .any(|bindings| bindings.is_pressed(key_event)); + let active_action = |action: crate::keymap::KeymapActionId| { + contexts.contains_action(action) + && if search_editing { + action.context == crate::keymap::KeymapContext::Editor + || matches!( + (action.context, action.action), + (crate::keymap::KeymapContext::Chat, "toggle_voice") + ) + } else { + matches!( + (action.context, action.action), + (crate::keymap::KeymapContext::Global, "find_transcript") + | (crate::keymap::KeymapContext::Chat, "toggle_voice") + | ( + crate::keymap::KeymapContext::Pager, + "find" + | "scroll_up" + | "scroll_down" + | "page_up" + | "page_down" + | "half_page_up" + | "half_page_down" + | "jump_top" + | "jump_bottom" + ) + ) || (self.overlay.is_some() + && action.context == crate::keymap::KeymapContext::Pager + && matches!(action.action, "close" | "close_transcript")) + } + }; + let completed_action = |dispatch| { + self.keymap.chords.bindings.iter().any(|binding| { + active_action(binding.action) + && crate::keymap::dispatch_event(binding.action) == Some(dispatch) + }) + }; + if fixed_search_control || fixed_select_control || pager_motion { + false + } else if self.key_chord_matcher.is_pending() { + match self + .key_chord_matcher + .clone() + .advance(key_event, &self.keymap.chords, contexts) + { + crate::keymap::KeyChordMatch::Completed(dispatch) => completed_action(dispatch), + crate::keymap::KeyChordMatch::Pending(prefix) => { + let (code, modifiers) = prefix.parts(); + self.keymap.chords.bindings.iter().any(|binding| { + active_action(binding.action) + && crate::keymap::chord_stroke_matches( + binding.chord.prefix, + KeyEvent::new(code, modifiers), + ) + }) + } + crate::keymap::KeyChordMatch::Ignored => true, + crate::keymap::KeyChordMatch::PassThrough + | crate::keymap::KeyChordMatch::Cancelled => false, + } + } else { + self.keymap.chords.bindings.iter().any(|binding| { + active_action(binding.action) + && crate::keymap::chord_stroke_matches(binding.chord.prefix, key_event) + }) + } + } +} diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 7c43e1799b26..e976c2bce1e6 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -485,6 +485,7 @@ impl App { | crossterm::event::MouseEventKind::ScrollDown ) { self.transcript_view.end_selection(&self.transcript_cells); + self.transcript_view.end_select_mode(); self.transcript_view.cancel_search(); self.transcript_view.clear_activity_focus(); } @@ -532,6 +533,16 @@ impl App { { self.chat_widget.end_composer_drag(); } + if let TuiEvent::Key(key) = event + && self.chat_widget.no_modal_or_popup_active() + && !self.transcript_view.is_select_mode() + && self.keymap.app.select_transcript.is_pressed(*key) + { + self.transcript_view + .begin_select_mode(&self.transcript_cells); + tui.frame_requester().schedule_frame(); + return Ok(true); + } // Read-only Escape belongs to session navigation, even when the transcript is scrolled. // Search and selection still consume Escape first to dismiss their interaction. if let TuiEvent::Key(key) = event @@ -627,7 +638,24 @@ impl App { { return Ok(false); } + let was_select_mode = self.transcript_view.is_select_mode(); + let select_exit_key = Self::transcript_select_exit_key(event); let action = match event { + TuiEvent::Key(key) if was_select_mode && !self.transcript_view.is_search_editing() => { + if let Some(action) = self + .transcript_view + .handle_select_mode_key(*key, &self.transcript_cells) + { + Some(action) + } else { + if self.keymap.app.find_transcript.is_pressed(*key) + || self.keymap.pager.find.is_pressed(*key) + { + self.transcript_view.begin_search(); + } + Some(ViewAction::Changed) + } + } TuiEvent::Key(key) if !self.transcript_view.is_search_editing() && self.keymap.app.find_transcript.is_pressed(*key) => @@ -645,6 +673,11 @@ impl App { .transcript_view .handle_mouse(*mouse, &self.transcript_cells), TuiEvent::Paste(text) => { + if self.transcript_view.is_select_mode() + && !self.transcript_view.is_search_editing() + { + return Ok(true); + } self.transcript_view.end_selection(&self.transcript_cells); if self.transcript_view.paste_search(text) { Some(ViewAction::Changed) @@ -677,15 +710,39 @@ impl App { } return self.handle_owned_backtrack_event(tui, event); }; - if let Some((characters, result)) = self.transcript_view.copy_action( - tui, - &self.transcript_cells, - &action, - crate::clipboard_copy::CopyFormat::Markdown, - ) { - self.transcript_view.show_copy_feedback(&result, characters); - } else if let ViewAction::OpenLink(url) = action { - self.open_url_in_browser(url); + let resume_following = matches!(action, ViewAction::CopyAndFollow(_)); + if !matches!(action, ViewAction::ExitSelectMode) { + if let Some((characters, result)) = self.transcript_view.copy_action( + tui, + &self.transcript_cells, + &action, + crate::clipboard_copy::CopyFormat::Markdown, + ) { + self.transcript_view.show_copy_feedback(&result, characters); + if resume_following { + match result { + Ok(crate::clipboard_copy::CopyStatus::Confirmed) => { + self.transcript_view.jump_to_latest(); + } + Ok(crate::clipboard_copy::CopyStatus::Pending(id)) => { + if let Some(key) = select_exit_key { + self.backtrack.pending_select_exit_key = Some((id, key)); + } + } + Ok( + crate::clipboard_copy::CopyStatus::Unconfirmed + | crate::clipboard_copy::CopyStatus::Busy, + ) + | Err(_) => {} + } + } + } else if let ViewAction::OpenLink(url) = action { + self.open_url_in_browser(url); + } + } + if was_select_mode && !self.transcript_view.is_select_mode() { + self.backtrack.pending_select_exit_key = None; + self.backtrack.select_exit_key = select_exit_key; } self.request_owned_history(tui, app_server); tui.frame_requester().schedule_frame(); diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index a68938e517dc..7195d7697ab2 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -867,6 +867,138 @@ async fn owned_search_and_selection_consume_input_before_composer_and_backtrack( Ok(()) } +#[tokio::test] +async fn keyboard_select_mode_preserves_the_draft_and_absorbs_enter_repeat() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + attach_thread(&mut app, ThreadId::new()); + app.transcript_cells = vec![user_cell("select this transcript")]; + app.chat_widget + .apply_external_edit("composer draft".to_string()); + app.keymap.app.find_transcript = vec![crate::key_hint::KeyBinding::from_event( + KeyCode::Char('v').into(), + )]; + app.keymap.pager.find = vec![crate::key_hint::KeyBinding::from_event( + KeyCode::Enter.into(), + )]; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?; + + for key in [ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyCode::Char('[').into(), + ] { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key)) + .await?; + } + assert!(app.transcript_view.is_select_mode()); + + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::Char('v').into()), + ) + .await?; + assert!(app.transcript_view.is_select_mode()); + assert!(!app.transcript_view.is_search_editing()); + for event in [ + TuiEvent::Paste("ignored".to_string()), + TuiEvent::Key(KeyCode::Esc.into()), + ] { + app.handle_tui_event(&mut tui, &mut app_server, event) + .await?; + } + assert!(app.transcript_view.is_select_mode()); + assert!(app.backtrack.select_exit_key.is_none()); + + for (kind, exit_guard) in [ + (KeyEventKind::Press, true), + (KeyEventKind::Repeat, false), + (KeyEventKind::Release, false), + ] { + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::NONE, + kind, + )), + ) + .await?; + assert_eq!(app.backtrack.select_exit_key.is_some(), exit_guard); + } + assert!(!app.transcript_view.is_select_mode()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "composer draft" + ); + + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn inline_select_mode_routes_find_and_returns_to_the_preserved_draft() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + attach_thread(&mut app, ThreadId::new()); + app.transcript_cells = vec![user_cell("needle in history")]; + app.chat_widget + .apply_external_edit("preserved inline draft".to_string()); + app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({ + "global": {"find_transcript": "f4"}, + "pager": {"scroll_down": "f4"} + }))?) + .expect("pager motion takes precedence in select mode"); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.open_transcript_overlay(&mut tui); + + for key in [ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyCode::Char('[').into(), + ] { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key)) + .await?; + } + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::F(4).into()), + ) + .await?; + assert!(matches!( + &app.overlay, + Some(Overlay::Transcript(overlay)) + if overlay.is_select_mode() && !overlay.is_search_editing() + )); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::F(3).into()), + ) + .await?; + assert!(matches!( + &app.overlay, + Some(Overlay::Transcript(overlay)) if overlay.is_search_editing() + )); + + for key in [KeyCode::Esc, KeyCode::Char('q')] { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key.into())) + .await?; + } + assert!(app.overlay.is_none()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "preserved inline draft" + ); + + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn inline_transcript_search_draws_and_escape_precedes_backtrack() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 7151dafd25bb..447a3c5b4f48 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -868,7 +868,10 @@ async fn preset_menu_keys_take_precedence_over_transcript_find() -> Result<()> { async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({ - "global": {"copy": ["ctrl-x ctrl-u"]}, + "global": { + "copy": ["ctrl-x ctrl-u"], + "find_transcript": ["ctrl-space f"] + }, "pager": { "scroll_up": ["ctrl-space x", "ctrl-home x", "ctrl-end x"], "close_transcript": ["ctrl-x ctrl-u"] @@ -884,6 +887,19 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re assert_eq!(app.route_key_chord_event(&mut tui, key), Some(key)); assert!(!app.key_chord_matcher.is_pending()); } + if let Some(crate::pager_overlay::Overlay::Transcript(overlay)) = &mut app.overlay { + overlay.begin_select_mode(); + let fixed = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL); + assert_eq!(app.route_key_chord_event(&mut tui, fixed), Some(fixed)); + assert!(!app.key_chord_matcher.is_pending()); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('q').into(), + ) + .await?; + } press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; press(&mut app, &mut tui, &mut app_server, ctrl('u')).await?; assert!(app.overlay.is_none()); @@ -938,6 +954,40 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re assert!(!app.transcript_view.is_detailed()); assert!(!app.key_chord_matcher.is_pending()); } + app.keymap = RuntimeKeymap::from_config(&toml::from_str( + "[global]\nfind_transcript = 'f6 f'\n[pager]\nscroll_down = 'f6'", + )?) + .expect("valid pager motion and global chord"); + app.transcript_cells = vec![std::sync::Arc::new( + crate::history_cell::PlainHistoryCell::new(vec!["first".into(), "second".into()]), + )]; + tui.set_owned_screen(/*owned*/ true)?; + app.render_owned_transcript( + &mut tui, + ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12), + )?; + assert!(app.transcript_view.begin_select_mode(&app.transcript_cells)); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('g').into(), + ) + .await?; + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('v').into(), + ) + .await?; + let before = app.transcript_view.selected_text(&app.transcript_cells); + press(&mut app, &mut tui, &mut app_server, KeyCode::F(6).into()).await?; + assert_ne!( + app.transcript_view.selected_text(&app.transcript_cells), + before + ); + assert!(!app.key_chord_matcher.is_pending()); tui.set_owned_screen(/*owned*/ false)?; app_server.shutdown().await?; Ok(()) @@ -946,6 +996,13 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re #[tokio::test] async fn selection_after_live_commit_uses_the_refreshed_frame() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; + let config: TuiKeymap = toml::from_str( + "[global]\ntoggle_side_conversation = []\nfind_transcript = 'ctrl-7 j'\n[pager]\nscroll_down = 'ctrl-7 j'", + )?; + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; let area = ratatui::layout::Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12, ); @@ -960,18 +1017,33 @@ async fn selection_after_live_commit_uses_the_refreshed_frame() -> Result<()> { overlay.insert_cell(std::sync::Arc::new( crate::history_cell::PlainHistoryCell::new(vec!["committed output".into()]), )); + overlay.begin_select_mode(); app.overlay = Some(crate::pager_overlay::Overlay::Transcript(overlay)); // The active cell has completed, but no Draw has reached the overlay yet. - app.handle_tui_event( + assert!(matches!( + &app.overlay, + Some(crate::pager_overlay::Overlay::Transcript(overlay)) if overlay.is_select_mode() + )); + press(&mut app, &mut tui, &mut app_server, ctrl('/')).await?; + assert!(app.key_chord_matcher.is_pending()); + let repeat = KeyEvent::new_with_kind( + KeyCode::Char('/'), + KeyModifiers::CONTROL, + KeyEventKind::Repeat, + ); + press(&mut app, &mut tui, &mut app_server, repeat).await?; + assert!(app.key_chord_matcher.is_pending()); + press( + &mut app, &mut tui, &mut app_server, - TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + KeyCode::Char('j').into(), ) .await?; let Some(crate::pager_overlay::Overlay::Transcript(overlay)) = &mut app.overlay else { panic!("overlay closed") }; - assert!(overlay.has_active_interaction()); + assert!(overlay.has_active_interaction() && !overlay.is_search_editing()); let mut buffer = ratatui::buffer::Buffer::empty(area); overlay.render(area, &mut buffer); let text = buffer diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index f3e5d5a844e8..56176cd353df 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -75,9 +75,43 @@ pub(crate) struct BacktrackState { pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, + /// Consume held-key input after a fresh press acts on transcript select mode. + pub(crate) select_exit_key: Option, + /// Preserve the initiating key while a select-and-follow copy is pending. + pub(crate) pending_select_exit_key: Option<(u64, crate::key_hint::KeyBinding)>, origin: Option, } +impl BacktrackState { + pub(crate) fn observe_pending_select_exit_key(&mut self, event: KeyEvent) { + if event.kind == KeyEventKind::Release + && self.pending_select_exit_key.is_some_and(|(_, binding)| { + binding.parts() == crate::key_hint::KeyBinding::from_event(event).parts() + }) + { + self.pending_select_exit_key = None; + } + } + + pub(crate) fn finish_pending_select_exit_key( + &mut self, + completed_id: u64, + select_mode_closed: bool, + ) -> Option { + match self.pending_select_exit_key { + Some((id, key)) if id == completed_id => { + self.pending_select_exit_key = None; + select_mode_closed.then_some(key) + } + Some((id, _)) if id < completed_id => { + self.pending_select_exit_key = None; + None + } + _ => None, + } + } +} + /// A user-visible backtrack choice that can be reopened in the current thread. #[derive(Debug, Clone, PartialEq)] pub(crate) struct BacktrackSelection { @@ -357,6 +391,7 @@ impl App { /// source of truth for the active cell and its cache invalidation key, and because `App` owns /// overlay lifecycle and frame scheduling for animations. fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + let select_exit_key = Self::transcript_select_exit_key(&event); if matches!( &event, TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained @@ -378,6 +413,8 @@ impl App { )) && let Some(Overlay::Transcript(t)) = &mut self.overlay { + let was_select_mode = t.is_select_mode(); + let pending_follow_before = t.pending_follow_id(); tui.set_overlay_input(tui::OverlayInput::Transcript)?; t.motion = crate::motion::MotionMode::from_animations_enabled( self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, @@ -407,8 +444,10 @@ impl App { if result.is_err() { let _ = tui.set_overlay_input(tui::OverlayInput::Default); } - result?; + let completed_copy = result?; let close_overlay = t.is_done(); + let select_mode_closed = was_select_mode && (!t.is_select_mode() || close_overlay); + let pending_follow_id = t.pending_follow_id(); if !close_overlay && active_key.is_some_and(|key| key.animation_tick.is_some()) && t.is_scrolled_to_bottom() @@ -416,6 +455,21 @@ impl App { tui.frame_requester() .schedule_frame_in(std::time::Duration::from_millis(50)); } + if pending_follow_id != pending_follow_before + && let (Some(id), Some(key)) = (pending_follow_id, select_exit_key) + { + self.backtrack.pending_select_exit_key = Some((id, key)); + } + let completed_exit_key = completed_copy.and_then(|completed_id| { + self.backtrack + .finish_pending_select_exit_key(completed_id, select_mode_closed) + }); + if select_mode_closed { + self.backtrack.select_exit_key = select_exit_key.or(completed_exit_key); + if select_exit_key.is_some() { + self.backtrack.pending_select_exit_key = None; + } + } if close_overlay { self.close_transcript_overlay(tui); tui.frame_requester().schedule_frame(); @@ -740,6 +794,38 @@ mod tests { .collect() } + #[test] + fn pending_select_exit_key_ends_on_completion_or_release() { + let key = crate::key_hint::KeyBinding::from_event(KeyCode::Enter.into()); + let mut state = BacktrackState { + pending_select_exit_key: Some((7, key)), + ..BacktrackState::default() + }; + + assert_eq!( + state.finish_pending_select_exit_key( + /*completed_id*/ 7, /*select_mode_closed*/ false, + ), + None + ); + assert_eq!(state.pending_select_exit_key, None); + state.pending_select_exit_key = Some((8, key)); + state.observe_pending_select_exit_key(KeyEvent::new_with_kind( + KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + KeyEventKind::Release, + )); + assert_eq!(state.pending_select_exit_key, None); + state.pending_select_exit_key = Some((9, key)); + assert_eq!( + state.finish_pending_select_exit_key( + /*completed_id*/ 9, /*select_mode_closed*/ true, + ), + Some(key) + ); + assert_eq!(state.pending_select_exit_key, None); + } + fn turn(turn_id: &str, status: TurnStatus, user_messages: usize) -> Turn { Turn { id: turn_id.to_string(), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 90e854d12876..ce17880041b1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -129,6 +129,8 @@ //! Multiline pastes continue blockquote prefixes. //! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is //! running, `Tab` submits just like Enter so input is never dropped. +//! Submit and queue bindings act only on fresh key presses; repeated events are consumed so a held +//! key cannot dispatch a preserved draft after another input layer closes. //! Vim Replace shares Insert's composer actions; only textarea editing differs. //! Literal completion/paste edits discard stale Replace recovery; subsequent typing records anew. //! Token markers added during Replace are removed when restoring overwritten characters. @@ -3528,6 +3530,11 @@ impl ChatComposer { } else { self.footer.mode = reset_mode_after_activity(self.footer.mode); } + if key_event.kind == KeyEventKind::Repeat + && (self.queue_keys.is_pressed(key_event) || self.submit_keys.is_pressed(key_event)) + { + return (InputResult::None, true); + } if self.queue_keys.is_pressed(key_event) && (self.is_task_running || self.queue_submissions || !self.is_bang_shell_command()) { diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 884cfe34ba62..6ac0a2678a79 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -66,6 +66,8 @@ pub(crate) use chords::KeyChordMatch; pub(crate) use chords::KeyChordMatcher; pub(crate) use chords::KeymapContextSet; pub(crate) use chords::RuntimeChordKeymap; +pub(crate) use chords::chord_stroke_matches; +pub(crate) use chords::dispatch_event; pub(crate) use chords::is_dispatch_token; pub(crate) use chords::is_dispatch_token_event; diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 40733cf61b4d..cebea5f85e3e 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -404,6 +404,8 @@ Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings." !(contexts.0 & TRANSCRIPT_CLOSE != 0 && binding.action.context == KeymapContext::Pager && binding.action.action == "close_transcript"), + !(contexts.0 & TRANSCRIPT_ACTIONS != 0 + && binding.action.context == KeymapContext::Pager), contexts.is_warnings() && binding.action.context != KeymapContext::List, contexts.is_warnings() && binding.action.context == KeymapContext::Global, ) @@ -690,7 +692,7 @@ pub(crate) fn is_dispatch_token_event(event: KeyEvent) -> bool { ) } -fn chord_stroke_matches(binding: KeyBinding, key_event: KeyEvent) -> bool { +pub(crate) fn chord_stroke_matches(binding: KeyBinding, key_event: KeyEvent) -> bool { let ctrl_slash = crate::key_hint::ctrl(KeyCode::Char('/')); let ctrl_seven = crate::key_hint::ctrl(KeyCode::Char('7')); diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index d77247cb78d8..48d4d9b21633 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -92,7 +92,7 @@ impl Overlay { }; tui.set_overlay_input(input)?; let result = match self { - Overlay::Transcript(o) => o.handle_event(tui, event), + Overlay::Transcript(o) => o.handle_event(tui, event).map(drop), Overlay::Static(o) => o.handle_event(tui, event), Overlay::Analytics(o) => o.handle_event(tui, event), }; diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index 62e27083e545..c8af27c6e59c 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -78,6 +78,7 @@ impl TranscriptOverlay { pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); let browsing = self.browsing_footer.is_some() + && !self.pending_select_mode && !self.view.has_active_interaction() && self.view.history != TranscriptHistoryState::Failed; let chrome_height = if browsing { 2 } else { 5 }; @@ -190,10 +191,18 @@ impl TranscriptOverlay { Ok(()) } - pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - if let Some(completion) = tui.clipboard.poll() { + pub(crate) fn handle_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result> { + let completed_copy = if let Some(completion) = tui.clipboard.poll() { + let id = completion.0; self.finish_pending_copy(completion); - } + Some(id) + } else { + None + }; if matches!(event, TuiEvent::Resume | TuiEvent::FocusLost) { self.view.cancel_primary(); self.view.end_drag(); @@ -219,7 +228,7 @@ impl TranscriptOverlay { } TuiEvent::Paste(text) => { if self.view.is_select_mode() && !self.view.is_search_editing() { - return Ok(()); + return Ok(completed_copy); } if self.view.is_search_editing() { self.view.end_selection(&self.cells); @@ -232,7 +241,7 @@ impl TranscriptOverlay { .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); } self.draw(tui)?; - return Ok(()); + return Ok(completed_copy); } TuiEvent::FocusLost => None, }; @@ -240,7 +249,7 @@ impl TranscriptOverlay { self.apply_action(tui, action); tui.frame_requester().schedule_frame(); } - Ok(()) + Ok(completed_copy) } pub(crate) fn is_done(&self) -> bool { @@ -263,19 +272,27 @@ impl TranscriptOverlay { } pub(crate) fn begin_select_mode(&mut self) { - self.pending_select_mode = !self.view.begin_select_mode(&self.cells); + self.pending_select_mode = true; } pub(crate) fn is_search_editing(&self) -> bool { self.view.is_search_editing() } + pub(crate) fn is_select_mode(&self) -> bool { + self.pending_select_mode || self.view.is_select_mode() + } + pub(crate) fn owns_interaction_key(&self, key: KeyEvent) -> bool { - self.view.owns_interaction_key(key) + self.pending_select_mode || self.view.owns_interaction_key(key) } pub(crate) fn has_active_interaction(&self) -> bool { - self.view.has_active_interaction() + self.pending_select_mode || self.view.has_active_interaction() + } + + pub(crate) fn pending_follow_id(&self) -> Option { + self.pending_follow_close.map(|(id, _)| id) } pub(crate) fn needs_history(&mut self) -> bool { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 2a573f1e9702..97eed4a6e231 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -116,6 +116,10 @@ pub(crate) fn running_in_vscode_terminal() -> bool { keyboard_modes::running_in_vscode_terminal() } +pub(crate) fn reports_key_releases() -> bool { + keyboard_modes::reports_key_releases() +} + fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool { match condition { NotificationCondition::Unfocused => !terminal_focused, diff --git a/codex-rs/tui/src/tui/alternate_screen.rs b/codex-rs/tui/src/tui/alternate_screen.rs index 2313da69dff6..3d1fc5cb1ee6 100644 --- a/codex-rs/tui/src/tui/alternate_screen.rs +++ b/codex-rs/tui/src/tui/alternate_screen.rs @@ -33,6 +33,7 @@ pub(super) static ALTERNATE_SCREEN: AlternateScreen = AlternateScreen { mouse_capture_disabled: AtomicBool::new(/*v*/ false), input_configured: AtomicBool::new(/*v*/ false), keyboard_active: AtomicBool::new(/*v*/ false), + parent_reports_key_releases: AtomicBool::new(/*v*/ false), link_pointer: super::link_pointer::LinkPointer::new(), }; @@ -47,6 +48,8 @@ pub(super) struct AlternateScreen { input_configured: AtomicBool, // An editor can take over the screen after Codex has popped its keyboard mode. keyboard_active: AtomicBool, + // Restore the main screen's input classification when this screen's keyboard frame is popped. + parent_reports_key_releases: AtomicBool, } /// Report pointer motion so the owned transcript can update its return-to-bottom hover state. @@ -87,6 +90,8 @@ impl AlternateScreen { // Stdout retains queued bytes on a flush error; cleanup must follow that pending entry. self.active.store(/*val*/ true, Ordering::Relaxed); writer.flush()?; + self.parent_reports_key_releases + .store(keyboard_modes::reports_key_releases(), Ordering::Relaxed); let mouse_capture = keyboard_modes::enable_keyboard_enhancement(writer); self.keyboard_active.store( !cfg!(windows) && !keyboard_modes::keyboard_enhancement_disabled(), @@ -174,6 +179,9 @@ impl AlternateScreen { let result = execute!(writer, PopKeyboardEnhancementFlags); if result.is_ok() { self.keyboard_active.store(/*val*/ false, Ordering::Relaxed); + keyboard_modes::set_reports_key_releases( + self.parent_reports_key_releases.load(Ordering::Relaxed), + ); } result } else { diff --git a/codex-rs/tui/src/tui/keyboard_modes.rs b/codex-rs/tui/src/tui/keyboard_modes.rs index 4dddb97f78dc..abf8cdd584e7 100644 --- a/codex-rs/tui/src/tui/keyboard_modes.rs +++ b/codex-rs/tui/src/tui/keyboard_modes.rs @@ -8,6 +8,8 @@ use std::fmt; use std::io::Write; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; @@ -18,6 +20,7 @@ use crossterm::event::PushKeyboardEnhancementFlags; use ratatui::crossterm::execute; const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT"; +static REPORTS_KEY_RELEASES: AtomicBool = AtomicBool::new(/*v*/ false); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum VscodeDetection { @@ -210,6 +213,7 @@ mod windows_term_program_tests; pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) -> super::tmux::MouseCapture { let tmux_options = super::tmux::options(); if keyboard_enhancement_disabled() { + set_reports_key_releases(/*reports*/ false); return tmux_options.mouse_capture; } @@ -220,14 +224,18 @@ pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) -> super::tmu None }; - let _ = execute!( + let flags = keyboard_enhancement_flags( + terminal_info().name, + running_in_tmux_session, + tmux_extended_keys_format, + ); + let result = execute!( writer, DisableModifyOtherKeys, - PushKeyboardEnhancementFlags(keyboard_enhancement_flags( - terminal_info().name, - running_in_tmux_session, - tmux_extended_keys_format - )) + PushKeyboardEnhancementFlags(flags) + ); + set_reports_key_releases( + result.is_ok() && flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES), ); if tmux_should_enable_modify_other_keys_for(running_in_tmux_session, tmux_extended_keys_format) @@ -258,6 +266,14 @@ fn keyboard_enhancement_flags( } } +pub(super) fn reports_key_releases() -> bool { + REPORTS_KEY_RELEASES.load(Ordering::Relaxed) +} + +pub(super) fn set_reports_key_releases(reports: bool) { + REPORTS_KEY_RELEASES.store(reports, Ordering::Relaxed); +} + fn running_in_tmux_session() -> bool { tmux_session_detected( std::env::var("TMUX").ok().as_deref(), @@ -280,16 +296,22 @@ fn tmux_should_enable_modify_other_keys_for( } pub(super) fn restore_keyboard_enhancement_stack(writer: &mut impl Write) { - let _ = execute!(writer, PopKeyboardEnhancementFlags, DisableModifyOtherKeys); + if execute!(writer, PopKeyboardEnhancementFlags, DisableModifyOtherKeys).is_ok() { + set_reports_key_releases(/*reports*/ false); + } } pub(super) fn reset_keyboard_reporting_after_exit(writer: &mut impl Write) { - let _ = execute!( + if execute!( writer, PopKeyboardEnhancementFlags, ResetKeyboardEnhancementFlags, DisableModifyOtherKeys - ); + ) + .is_ok() + { + set_reports_key_releases(/*reports*/ false); + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] From 480ab2d0061e004bc594a9f3f1ac07994e853522 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sun, 25 Oct 2026 16:19:41 +0000 Subject: [PATCH 047/287] Add managed enforcement of DELETE journaling on NFS (#57225) ## Why Existing databases retain their journal mode, so selecting `DELETE` for new NFS databases leaves existing WAL databases unchanged. ## What changed - Add the managed `unsafe_enforce_sqlite_journal_mode` requirement to migrate existing NFS databases before opening writable pools and require `DELETE` on each connection. - Share migration code between startup and `codex migrate-nfs-db`, preserving WAL backups and resuming interrupted replacements, including lazily opened databases. - Hold the migration lock through pool initialization and keep migration failures out of automatic corruption recovery. - Preserve local logs behavior and skip legacy NFS logs cleanup when enforcement is enabled. ## Testing Add coverage for requirement precedence, database settings, data and backup preservation across restarts, interrupted replacement recovery, refusal of active WAL databases, migration lock waiting, and local logs remaining in WAL mode. GitOrigin-RevId: da41ec1b58b1ee8092591cdf8b358280928ff9d4 --- codex-rs/Cargo.lock | 3 +- codex-rs/cli/Cargo.toml | 1 - codex-rs/cli/src/bin/logs_client.rs | 3 +- codex-rs/cli/src/migrate_nfs_db.rs | 254 ++--------------- codex-rs/cli/tests/doctor_path_safety.rs | 4 +- codex-rs/cli/tests/worktree.rs | 2 +- codex-rs/config/src/config_requirements.rs | 27 ++ .../config/src/requirements_layers/stack.rs | 5 + .../src/requirements_layers/stack_tests.rs | 3 + codex-rs/core/src/config/config_tests.rs | 17 +- codex-rs/core/src/config/mod.rs | 8 +- .../examples/configure_board.rs | 3 +- codex-rs/state/Cargo.toml | 2 + codex-rs/state/src/lib.rs | 3 + codex-rs/state/src/nfs_migration.rs | 262 ++++++++++++++++++ .../src/nfs_migration}/access.rs | 0 .../src/nfs_migration}/replacement.rs | 0 codex-rs/state/src/runtime.rs | 2 +- codex-rs/state/src/sqlite.rs | 148 +++------- codex-rs/state/src/sqlite/connection.rs | 122 ++++++++ codex-rs/state/src/sqlite/nfs.rs | 66 +++++ codex-rs/state/src/sqlite_tests.rs | 164 ++++++++++- codex-rs/thread-manager-sample/src/main.rs | 5 +- codex-rs/tui/src/debug_config.rs | 1 + 24 files changed, 747 insertions(+), 358 deletions(-) create mode 100644 codex-rs/state/src/nfs_migration.rs rename codex-rs/{cli/src/migrate_nfs_db => state/src/nfs_migration}/access.rs (100%) rename codex-rs/{cli/src/migrate_nfs_db => state/src/nfs_migration}/replacement.rs (100%) create mode 100644 codex-rs/state/src/sqlite/connection.rs create mode 100644 codex-rs/state/src/sqlite/nfs.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 962dfa6219bc..12a8a8bb0fae 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2571,7 +2571,6 @@ dependencies = [ "insta", "jsonwebtoken", "libc", - "libsqlite3-sys", "os_info", "owo-colors", "predicates", @@ -4803,6 +4802,7 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "file-id", + "libc", "libsqlite3-sys", "log", "pretty_assertions", @@ -4813,6 +4813,7 @@ dependencies = [ "sqlx-macros", "sqlx-sqlite", "strum 0.27.2", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c812cf988731..8cc0bce5d8a7 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -87,7 +87,6 @@ crossterm = { workspace = true, features = ["event-stream"] } futures = { workspace = true } http = { workspace = true } libc = { workspace = true } -libsqlite3-sys = { workspace = true } os_info = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/cli/src/bin/logs_client.rs b/codex-rs/cli/src/bin/logs_client.rs index 32ef9323ed6a..e959c33df16e 100644 --- a/codex-rs/cli/src/bin/logs_client.rs +++ b/codex-rs/cli/src/bin/logs_client.rs @@ -134,8 +134,9 @@ async fn resolve_sqlite_config(args: &Args) -> anyhow::Result { .parent() .map(ToOwned::to_owned) .unwrap_or_else(|| PathBuf::from(".")); - return Ok(SqliteConfig::from_sqlite_home( + return Ok(SqliteConfig::from_sqlite_home_with_nfs_enforcement( AbsolutePathBuf::relative_to_current_dir(sqlite_home)?, + /*enforce_delete_on_nfs*/ false, )); } diff --git a/codex-rs/cli/src/migrate_nfs_db.rs b/codex-rs/cli/src/migrate_nfs_db.rs index 254102d032e1..02c296d4f067 100644 --- a/codex-rs/cli/src/migrate_nfs_db.rs +++ b/codex-rs/cli/src/migrate_nfs_db.rs @@ -1,37 +1,17 @@ -//! Convert configured databases to DELETE mode and keep their WAL originals in backup directories. -//! Require confirmation before opening database files. All clients must remain stopped. -//! Keep source descriptors open until SQLite closes. Do not checkpoint the WAL source. -//! NFS must enforce POSIX locks across clients. Local descriptor checks cannot prove this. +//! Confirms explicit database migration and reports the shared migrator's results. +//! Other SQLite clients must remain stopped during replacement, and NFS must enforce +//! cross-client POSIX locks. use anyhow::Context; use anyhow::ensure; use clap::Parser; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; +use codex_state::NfsDbMigration; +use codex_state::NfsDbMigrationOutcome; use codex_utils_cli::CliConfigOverrides; use owo_colors::OwoColorize; -use sqlx::ConnectOptions; -use sqlx::Connection; -use sqlx::SqliteConnection; -use sqlx::sqlite::SqliteConnectOptions; -use sqlx::sqlite::SqliteJournalMode; -use sqlx::sqlite::SqliteLockingMode; -use sqlx::sqlite::SqliteSynchronous; -use std::fs::File; -use std::io::ErrorKind; -use std::io::Read; -use std::io::Write; -use std::path::Path; use std::path::PathBuf; -use std::time::Duration; - -#[cfg(unix)] -mod access; -mod replacement; - -use replacement::Recovery; -use replacement::Replacement; -use replacement::regular_file_exists; #[derive(Debug, Parser)] #[command( @@ -83,226 +63,28 @@ pub(crate) async fn run( println!("No databases found."); return Ok(()); } - // Keep this file after release. Removing it lets two migrations lock different inodes. - let migration_lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(sqlite.home().join(".codex-nfs-migration.lock"))?; - migration_lock - .try_lock() - .context("another database migration is running")?; + let mut migration = NfsDbMigration::lock(sqlite.home())?; for database in sqlite.runtime_db_paths() { if skip_logs && database.path == sqlite.logs_db_path() { println!("Skipped logs database: log_dir is configured."); continue; } - migrate(&database.path, &command.staging_dir).await.with_context(|| format!( + let outcome = migration.migrate(&database.path, &command.staging_dir).await.with_context(|| format!( "migration failed for {}. Keep all Codex instances stopped. Correct the error and rerun codex migrate-nfs-db", database.path.display() ))?; - } - println!("Migration completed successfully. You can restart Codex."); - Ok(()) -} - -#[expect( - clippy::disallowed_methods, - reason = "Migration needs dedicated exclusive connections, not the runtime's WAL pool" -)] -async fn migrate(source: &Path, staging_dir: &Path) -> anyhow::Result<()> { - let replacement = Replacement::new(source)?; - if let Recovery::Installed = replacement.resume().await? { - println!( - "Migrated database: {}\nWAL backup: {}", - source.display(), - replacement.backup_directory.display() - ); - return Ok(()); - } - let exists = regular_file_exists(source)?; - for suffix in ["-wal", "-shm", "-journal"] { - let mut path = source.as_os_str().to_owned(); - path.push(suffix); - let sidecar_exists = regular_file_exists(Path::new(&path))?; - ensure!( - exists || !sidecar_exists, - "database is missing but a SQLite sidecar exists" - ); - } - if !exists { - return Ok(()); - } - let source_file = File::open(source)?; - #[cfg(unix)] - access::require_private_database(&source_file)?; - // The private directory protects WAL contents even on a shared /tmp. - let staging = tempfile::Builder::new() - .prefix("codex-nfs-migration-") - .tempdir_in(staging_dir) - .context("cannot create local staging directory")?; - let staged_database = staging.path().join("database.sqlite"); - let options = SqliteConnectOptions::new() - .filename(source) - // Set this mode before SQLite first accesses the WAL. - // SQLite then locks the database file without shared memory across NFS client kernels. - .locking_mode(SqliteLockingMode::Exclusive) - .synchronous(SqliteSynchronous::Full) - .busy_timeout(Duration::ZERO) - .disable_statement_logging(); - let mut connection = SqliteConnection::connect_with(&options).await.context( - "cannot open source database exclusively; close its SQLite clients on all hosts", - )?; - - // Closing any descriptor for an inode can release this process's POSIX record locks - // for that inode. - // Keep both source files open until SQLite closes its connection, including after an error. - let mut source_files = vec![source_file]; - let result: anyhow::Result = async { - { - let mut handle = connection.lock_handle().await?; - // SAFETY: SQLx's guard prevents its worker from accessing this open connection. - // This db_config option accepts an int and an optional output pointer to an int. - let status = unsafe { - libsqlite3_sys::sqlite3_db_config( - handle.as_raw_handle().as_ptr(), - libsqlite3_sys::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, - /*onoff*/ 1, - std::ptr::null_mut::(), - ) - }; - ensure!( - status == libsqlite3_sys::SQLITE_OK, - "cannot disable source checkpoint on close: {status}" - ); - } - let mode: String = sqlx::query_scalar("PRAGMA journal_mode") - .fetch_one(&mut connection) - .await - .context( - "cannot access source database exclusively; close its SQLite clients on all hosts", - )?; - if mode == "delete" { - ensure!( - !regular_file_exists(&replacement.replacement_path())?, - "unfinished backup exists" - ); - println!("Already in DELETE mode: {}", source.display()); - return Ok(false); - } - ensure!( - mode == "wal", - "source database must be in WAL mode (found {mode})" - ); - // BEGIN EXCLUSIVE alone blocks only WAL writers. EXCLUSIVE locking_mode also blocks readers. - // The mode keeps the database lock after ROLLBACK. - sqlx::raw_sql("BEGIN EXCLUSIVE; ROLLBACK;") - .execute(&mut connection) - .await - .context( - "cannot lock source database exclusively; close its SQLite clients on all hosts", - )?; - - let mut wal_path = source.as_os_str().to_owned(); - wal_path.push("-wal"); - match File::open(wal_path) { - Ok(file) => source_files.push(file), - Err(error) if error.kind() == ErrorKind::NotFound => {} - Err(error) => return Err(error).context("cannot open source WAL"), - } - for (file, name) in source_files - .iter_mut() - .zip(["database.sqlite", "database.sqlite-wal"]) - { - let mut target = File::create(staging.path().join(name))?; - copy_buffered(file, &mut target).context("cannot stage source database and WAL")?; + match outcome { + NfsDbMigrationOutcome::Missing => {} + NfsDbMigrationOutcome::AlreadyDelete => { + println!("Already in DELETE mode: {}", database.path.display()) + } + NfsDbMigrationOutcome::Migrated { backup_directory } => println!( + "Migrated database: {}\nWAL backup: {}", + database.path.display(), + backup_directory.display() + ), } - let mut copy = SqliteConnection::connect_with( - &SqliteConnectOptions::new() - .filename(&staged_database) - .locking_mode(SqliteLockingMode::Exclusive) - .journal_mode(SqliteJournalMode::Delete) - .synchronous(SqliteSynchronous::Full) - .disable_statement_logging(), - ) - .await - .context("cannot convert staged database to DELETE")?; - let validated = validate_delete_database(&mut copy) - .await - .context("staged database failed validation"); - let closed = copy.close().await; - validated?; - closed?; - - replacement.prepare_backup_directory()?; - let mut temporary = tempfile::NamedTempFile::new_in(&replacement.backup_directory)?; - copy_buffered(&mut File::open(&staged_database)?, temporary.as_file_mut()) - .context("cannot write replacement copy")?; - // Use the open handle. Closing another source descriptor can release SQLite's locks. - temporary - .as_file() - .set_permissions(source_files[0].metadata()?.permissions())?; - #[cfg(unix)] - access::require_private_database(temporary.as_file())?; - temporary.as_file().sync_all()?; - temporary - .into_temp_path() - .persist_noclobber(replacement.replacement_path()) - .context("cannot save replacement copy")?; - File::open(&replacement.backup_directory)?.sync_all()?; - for file in &source_files { - file.sync_all()?; - } - replacement.move_original_to_backup()?; - Ok(true) } - .await; - let closed = connection.close().await; - drop(source_files); - let changed = result?; - closed.context("cannot close source database. Keep clients stopped")?; - if changed { - replacement.install()?; - println!( - "Migrated database: {}\nWAL backup: {}", - source.display(), - replacement.backup_directory.display() - ); - } - Ok(()) -} - -async fn validate_delete_database(connection: &mut SqliteConnection) -> anyhow::Result<()> { - let mode: String = sqlx::query_scalar("PRAGMA journal_mode") - .fetch_one(&mut *connection) - .await?; - ensure!( - mode == "delete", - "database did not enter DELETE journal mode" - ); - let integrity: String = sqlx::query_scalar("PRAGMA integrity_check(1)") - .fetch_one(connection) - .await - .context("database failed integrity check")?; - ensure!( - integrity == "ok", - "database failed integrity check: {integrity}" - ); + println!("Migration completed successfully. You can restart Codex."); Ok(()) } - -fn copy_buffered(source: &mut File, destination: &mut File) -> std::io::Result<()> { - // Use large reads to avoid the fallback to small reads for copy_file_range on NFSv4.1. - let mut buffer = vec![0; 1024 * 1024]; - loop { - let bytes = match source.read(&mut buffer) { - Err(error) if error.kind() == ErrorKind::Interrupted => continue, - result => result?, - }; - if bytes == 0 { - return Ok(()); - } - destination.write_all(&buffer[..bytes])?; - } -} diff --git a/codex-rs/cli/tests/doctor_path_safety.rs b/codex-rs/cli/tests/doctor_path_safety.rs index cd5d7182ec03..66d6d2c0cfc7 100644 --- a/codex-rs/cli/tests/doctor_path_safety.rs +++ b/codex-rs/cli/tests/doctor_path_safety.rs @@ -217,7 +217,7 @@ fn doctor_reports_failed_database_paths() -> Result<()> { let fixture = Fixture::new()?; let sqlite_home = fixture.root.path().join("sqlite"); std::fs::create_dir(&sqlite_home)?; - let sqlite = SqliteConfig::from_sqlite_home(AbsolutePathBuf::try_from(sqlite_home.clone())?); + let sqlite = SqliteConfig::new_for_testing(AbsolutePathBuf::try_from(sqlite_home.clone())?); let config_file = fixture.home.join("config.toml"); let config = std::fs::read_to_string(&config_file)?; let sqlite_home = toml::Value::String(sqlite_home.display().to_string()); @@ -311,7 +311,7 @@ fn doctor_redacts_failed_database_paths_in_json() -> Result<()> { let fixture = Fixture::new()?; let sqlite_home = fixture.root.path().join("doctor-secret-sqlite"); std::fs::create_dir(&sqlite_home)?; - let sqlite = SqliteConfig::from_sqlite_home(AbsolutePathBuf::try_from(sqlite_home.clone())?); + let sqlite = SqliteConfig::new_for_testing(AbsolutePathBuf::try_from(sqlite_home.clone())?); let config_file = fixture.home.join("config.toml"); let config = std::fs::read_to_string(&config_file)?; let sqlite_home = toml::Value::String(sqlite_home.display().to_string()); diff --git a/codex-rs/cli/tests/worktree.rs b/codex-rs/cli/tests/worktree.rs index 55cc4dfd9872..13b6914968de 100644 --- a/codex-rs/cli/tests/worktree.rs +++ b/codex-rs/cli/tests/worktree.rs @@ -819,7 +819,7 @@ trust_level = "trusted" assert!(context.contains("managed cloud instructions"), "{context}"); if previous.is_empty() { // A legacy summary still says launcher A, but persisted turns use checkout B. - let sqlite = codex_state::SqliteConfig::from_sqlite_home( + let sqlite = codex_state::SqliteConfig::new_for_testing( codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&home)?, ); let db = sqlite.open_read_write_pool(&sqlite.state_db_path()).await?; diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 961e3c3d78f9..e64105884441 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -166,6 +166,7 @@ pub struct ConfigRequirements { pub cli_auth_credentials_store: Option>, pub chatgpt_base_url: Option>, pub sqlite_home: Option>, + pub unsafe_enforce_sqlite_journal_mode: Option>, pub log_dir: Option>, pub model_catalog_json: Option>, pub model_provider: Option>, @@ -211,6 +212,7 @@ impl Default for ConfigRequirements { cli_auth_credentials_store: None, chatgpt_base_url: None, sqlite_home: None, + unsafe_enforce_sqlite_journal_mode: None, log_dir: None, model_catalog_json: None, model_provider: None, @@ -1049,6 +1051,8 @@ pub struct ConfigRequirementsToml { pub cli_auth_credentials_store: Option, pub chatgpt_base_url: Option, pub sqlite_home: Option, + /// Require migration of existing WAL databases on NFS before opening writable pools. + pub unsafe_enforce_sqlite_journal_mode: Option, pub log_dir: Option, pub model_catalog_json: Option, /// Exact provider selection, overriding local and session configuration. @@ -1161,6 +1165,7 @@ pub struct ConfigRequirementsWithSources { pub cli_auth_credentials_store: Option>, pub chatgpt_base_url: Option>, pub sqlite_home: Option>, + pub unsafe_enforce_sqlite_journal_mode: Option>, pub log_dir: Option>, pub model_catalog_json: Option>, pub model_provider: Option>, @@ -1225,6 +1230,7 @@ impl ConfigRequirementsWithSources { cli_auth_credentials_store: _, chatgpt_base_url: _, sqlite_home: _, + unsafe_enforce_sqlite_journal_mode: _, log_dir: _, model_catalog_json: _, model_provider: _, @@ -1291,6 +1297,7 @@ impl ConfigRequirementsWithSources { cli_auth_credentials_store, chatgpt_base_url, sqlite_home, + unsafe_enforce_sqlite_journal_mode, log_dir, model_catalog_json, model_provider, @@ -1378,6 +1385,7 @@ impl ConfigRequirementsWithSources { cli_auth_credentials_store, chatgpt_base_url, sqlite_home, + unsafe_enforce_sqlite_journal_mode, log_dir, model_catalog_json, model_provider, @@ -1423,6 +1431,8 @@ impl ConfigRequirementsWithSources { cli_auth_credentials_store: cli_auth_credentials_store.map(|sourced| sourced.value), chatgpt_base_url: chatgpt_base_url.map(|sourced| sourced.value), sqlite_home: sqlite_home.map(|sourced| sourced.value), + unsafe_enforce_sqlite_journal_mode: unsafe_enforce_sqlite_journal_mode + .map(|sourced| sourced.value), log_dir: log_dir.map(|sourced| sourced.value), model_catalog_json: model_catalog_json.map(|sourced| sourced.value), model_provider: model_provider.map(|sourced| sourced.value), @@ -1536,6 +1546,7 @@ impl ConfigRequirementsToml { && self.cli_auth_credentials_store.is_none() && self.chatgpt_base_url.is_none() && self.sqlite_home.is_none() + && self.unsafe_enforce_sqlite_journal_mode.is_none() && self.log_dir.is_none() && self.model_catalog_json.is_none() && self.model_provider.is_none() @@ -1780,6 +1791,7 @@ impl TryFrom for ConfigRequirements { cli_auth_credentials_store, chatgpt_base_url, sqlite_home, + unsafe_enforce_sqlite_journal_mode, log_dir, model_catalog_json, model_provider, @@ -2170,6 +2182,7 @@ impl TryFrom for ConfigRequirements { cli_auth_credentials_store, chatgpt_base_url, sqlite_home, + unsafe_enforce_sqlite_journal_mode, log_dir, model_catalog_json, model_provider, @@ -2334,6 +2347,7 @@ mod tests { cli_auth_credentials_store, chatgpt_base_url, sqlite_home, + unsafe_enforce_sqlite_journal_mode, log_dir, model_catalog_json, model_provider, @@ -2385,6 +2399,8 @@ mod tests { chatgpt_base_url: chatgpt_base_url .map(|value| Sourced::new(value, RequirementSource::Unknown)), sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)), + unsafe_enforce_sqlite_journal_mode: unsafe_enforce_sqlite_journal_mode + .map(|value| Sourced::new(value, RequirementSource::Unknown)), log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)), model_catalog_json: model_catalog_json .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -2916,6 +2932,7 @@ mod tests { cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring), chatgpt_base_url: Some("https://managed.example/backend-api/".to_string()), sqlite_home: Some(sqlite_home.clone()), + unsafe_enforce_sqlite_journal_mode: Some(true), log_dir: Some(log_dir.clone()), model_catalog_json: Some(model_catalog_json.clone()), model_provider: Some("gateway".to_string()), @@ -2979,6 +2996,10 @@ mod tests { source.clone(), )), sqlite_home: Some(Sourced::new(sqlite_home, source.clone())), + unsafe_enforce_sqlite_journal_mode: Some(Sourced::new( + /*value*/ true, + source.clone() + )), log_dir: Some(Sourced::new(log_dir, source.clone())), model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())), model_provider: Some(Sourced::new("gateway".to_string(), source.clone())), @@ -3109,6 +3130,7 @@ mod tests { let mut populated_target = ConfigRequirementsWithSources::default(); let populated_requirements: ConfigRequirementsToml = from_str( r#" + unsafe_enforce_sqlite_journal_mode = true allowed_approval_policies = ["never"] guardian_extra_policy = "Use the managed extra policy." "#, @@ -3117,6 +3139,7 @@ mod tests { let source: ConfigRequirementsToml = from_str( r#" + unsafe_enforce_sqlite_journal_mode = false allowed_approval_policies = ["on-request"] guardian_extra_policy = "Use the lower-priority extra policy." "#, @@ -3130,6 +3153,10 @@ mod tests { assert_eq!( populated_target, ConfigRequirementsWithSources { + unsafe_enforce_sqlite_journal_mode: Some(Sourced::new( + /*value*/ true, + existing_source.clone() + )), allowed_approval_policies: Some(Sourced::new( vec![AskForApproval::Never], existing_source.clone(), diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 68f4261bb2f4..4dd6e255ba6b 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -223,6 +223,7 @@ fn populate_merged_regular_fields_with_sources( cli_auth_credentials_store, chatgpt_base_url, sqlite_home, + unsafe_enforce_sqlite_journal_mode, log_dir, model_catalog_json, model_provider, @@ -269,6 +270,10 @@ fn populate_merged_regular_fields_with_sources( set_sourced!(cli_auth_credentials_store, &["cli_auth_credentials_store"]); set_sourced!(chatgpt_base_url, &["chatgpt_base_url"]); set_sourced!(sqlite_home, &["sqlite_home"]); + set_sourced!( + unsafe_enforce_sqlite_journal_mode, + &["unsafe_enforce_sqlite_journal_mode"] + ); set_sourced!(log_dir, &["log_dir"]); set_sourced!(model_catalog_json, &["model_catalog_json"]); set_sourced!(model_provider, &["model_provider"]); diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 2e9bebde11bd..e1c9d9fe22d2 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -102,6 +102,7 @@ allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["workspace-write"] default_permissions = ":workspace" allow_remote_control = true +unsafe_enforce_sqlite_journal_mode = false additional_developer_instructions = "Lower-priority instructions." [allowed_permission_profiles] @@ -117,6 +118,7 @@ allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] default_permissions = ":read-only" allow_remote_control = false +unsafe_enforce_sqlite_journal_mode = true additional_developer_instructions = "" [allowed_permission_profiles] @@ -136,6 +138,7 @@ allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] default_permissions = ":read-only" allow_remote_control = false +unsafe_enforce_sqlite_journal_mode = true additional_developer_instructions = "" [allowed_permission_profiles] diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 16c6fc8ef51f..97217c5ce669 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10660,6 +10660,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() cli_auth_credentials_store: None, chatgpt_base_url: None, sqlite_home: None, + unsafe_enforce_sqlite_journal_mode: None, log_dir: None, model_catalog_json: None, check_for_update_on_startup: None, @@ -13835,8 +13836,12 @@ async fn load_with_enterprise_requirement( .await } +#[test_case::test_case(true; "enforce_sqlite_journal_mode")] +#[test_case::test_case(false; "preserve_sqlite_journal_mode")] #[tokio::test] -async fn exact_requirements_apply_to_runtime_config() -> std::io::Result<()> { +async fn exact_requirements_apply_to_runtime_config( + enforce_delete_on_nfs: bool, +) -> std::io::Result<()> { let codex_home = TempDir::new()?; let unused_diagnostics = codex_home.path().join("unused-diagnostics"); std::fs::write(&unused_diagnostics, "not a directory")?; @@ -13855,6 +13860,7 @@ async fn exact_requirements_apply_to_runtime_config() -> std::io::Result<()> { log_dir = {unused_diagnostics:?} check_for_update_on_startup = true allow_login_shell = true +unsafe_enforce_sqlite_journal_mode = false [feedback] enabled = true @@ -13867,6 +13873,7 @@ enabled = true let requirements = format!( r#" sqlite_home = {:?} +unsafe_enforce_sqlite_journal_mode = {enforce_delete_on_nfs} log_dir = {:?} model_catalog_json = {:?} check_for_update_on_startup = false @@ -13881,7 +13888,13 @@ enabled = false ); let config = load_with_enterprise_requirement(&codex_home, requirements).await?; - assert_eq!(config.sqlite.home(), required_sqlite_home.as_path()); + assert_eq!( + config.sqlite, + codex_state::SqliteConfig::new_for_testing(AbsolutePathBuf::try_from( + required_sqlite_home.clone() + )?) + .enable_delete_mode_on_nfs(enforce_delete_on_nfs) + ); assert_eq!(config.log_dir, required_log_dir); assert_eq!( ( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e30c8db539a1..feb68b25234f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3429,6 +3429,7 @@ impl Config { cli_auth_credentials_store, chatgpt_base_url: _, sqlite_home: _, + unsafe_enforce_sqlite_journal_mode, log_dir: _, model_catalog_json: _, model_provider: _, @@ -4312,7 +4313,12 @@ impl Config { .cloned() .or(sqlite_home_env) .unwrap_or_else(|| codex_home.clone()); - let mut sqlite = codex_state::SqliteConfig::from_sqlite_home(sqlite_home); + let enforce_delete_on_nfs = + unsafe_enforce_sqlite_journal_mode.is_some_and(|required| required.value); + let mut sqlite = codex_state::SqliteConfig::from_sqlite_home_with_nfs_enforcement( + sqlite_home, + enforce_delete_on_nfs, + ); let requirements = config_layer_stack.requirements_toml(); if cfg.log_dir.is_some() && requirements.sqlite_home.is_none() { sqlite = sqlite.with_local_logs_home(log_dir.clone()); diff --git a/codex-rs/ext/agent-message-board/examples/configure_board.rs b/codex-rs/ext/agent-message-board/examples/configure_board.rs index 978c611b83fb..712011b3cf3b 100644 --- a/codex-rs/ext/agent-message-board/examples/configure_board.rs +++ b/codex-rs/ext/agent-message-board/examples/configure_board.rs @@ -13,8 +13,9 @@ async fn main() -> Result<(), Box> { let [_, sqlite_home, board, input] = args.as_slice() else { return Err("Usage: configure_board ".into()); }; - let sqlite = SqliteConfig::from_sqlite_home( + let sqlite = SqliteConfig::from_sqlite_home_with_nfs_enforcement( std::path::absolute(PathBuf::from(sqlite_home))?.try_into()?, + /*enforce_delete_on_nfs*/ false, ); let board = SessionId::from_string(board.to_str().ok_or("invalid board ID")?)?; let input = tokio::fs::read(input).await?; diff --git a/codex-rs/state/Cargo.toml b/codex-rs/state/Cargo.toml index 06eb33896397..bc32feccab09 100644 --- a/codex-rs/state/Cargo.toml +++ b/codex-rs/state/Cargo.toml @@ -12,6 +12,7 @@ codex-history = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } file-id = { workspace = true } +libc = { workspace = true } libsqlite3-sys = { workspace = true } log = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -20,6 +21,7 @@ sqlx = { workspace = true } sqlx-macros = { workspace = true } sqlx-sqlite = { workspace = true } strum = { workspace = true, features = ["derive"] } +tempfile = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt-multi-thread", "sync", "time"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 78aea77025ff..5c5ecad54b1f 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -15,6 +15,7 @@ mod extract; pub mod log_db; mod migrations; mod model; +mod nfs_migration; mod paths; mod runtime; mod sqlite; @@ -34,6 +35,8 @@ pub use model::QueuedUserSubmissionRecord; pub use model::RolloutMigrationCursor; pub use model::RolloutMigrationSkippedRollout; pub use model::RolloutMigrationState; +pub use nfs_migration::NfsDbMigration; +pub use nfs_migration::NfsDbMigrationOutcome; pub use runtime::GuardianReviewRecord; pub use runtime::MAX_GUARDIAN_REVIEW_BYTES; pub use runtime::MAX_GUARDIAN_REVIEW_RECORDS; diff --git a/codex-rs/state/src/nfs_migration.rs b/codex-rs/state/src/nfs_migration.rs new file mode 100644 index 000000000000..160eabd046b4 --- /dev/null +++ b/codex-rs/state/src/nfs_migration.rs @@ -0,0 +1,262 @@ +//! Converts WAL databases to DELETE mode while preserving recoverable WAL backups. +//! Callers serialize startup and migration with the directory lock. Other SQLite clients +//! must remain stopped during replacement; NFS must enforce cross-client POSIX locks. + +use anyhow::Context; +use anyhow::ensure; +use sqlx::ConnectOptions; +use sqlx::Connection; +use sqlx::SqliteConnection; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::sqlite::SqliteJournalMode; +use sqlx::sqlite::SqliteLockingMode; +use sqlx::sqlite::SqliteSynchronous; +use std::fs::File; +use std::io::ErrorKind; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(unix)] +mod access; +mod replacement; + +use replacement::Recovery; +use replacement::Replacement; +use replacement::regular_file_exists; + +/// Holds the directory lock shared by automatic startup and the migration command. +pub struct NfsDbMigration { + _lock: File, +} + +/// Result of checking or converting a database, for caller-owned reporting. +pub enum NfsDbMigrationOutcome { + Missing, + AlreadyDelete, + Migrated { backup_directory: PathBuf }, +} + +impl NfsDbMigration { + pub fn lock(sqlite_home: &Path) -> anyhow::Result { + ensure!( + cfg!(unix), + "NFS database migration requires Unix filesystem locking and directory fsync" + ); + // Never remove the lock: concurrent openers must lock the same inode. + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(sqlite_home.join(".codex-nfs-migration.lock"))?; + lock.try_lock() + .context("another database migration is running")?; + Ok(Self { _lock: lock }) + } + + /// Convert an existing WAL database or resume an interrupted replacement. + /// All other clients must remain stopped until this returns successfully. + #[expect( + clippy::disallowed_methods, + reason = "Migration needs a dedicated exclusive connection, not a runtime pool" + )] + pub async fn migrate( + &mut self, + source: &Path, + staging_dir: &Path, + ) -> anyhow::Result { + let replacement = Replacement::new(source)?; + if let Recovery::Installed = replacement.resume().await? { + return Ok(NfsDbMigrationOutcome::Migrated { + backup_directory: replacement.backup_directory, + }); + } + let exists = regular_file_exists(source)?; + for suffix in ["-wal", "-shm", "-journal"] { + let mut path = source.as_os_str().to_owned(); + path.push(suffix); + let sidecar_exists = regular_file_exists(Path::new(&path))?; + ensure!( + exists || !sidecar_exists, + "database is missing but a SQLite sidecar exists" + ); + } + if !exists { + return Ok(NfsDbMigrationOutcome::Missing); + } + let options = SqliteConnectOptions::new() + .filename(source) + // Set this mode before SQLite first accesses the WAL. + // SQLite then locks the database file without shared memory across NFS client kernels. + .locking_mode(SqliteLockingMode::Exclusive) + .synchronous(SqliteSynchronous::Full) + .busy_timeout(Duration::ZERO) + .disable_statement_logging(); + let mut connection = SqliteConnection::connect_with(&options).await.context( + "cannot open source database exclusively; close its SQLite clients on all hosts", + )?; + + // Closing any descriptor for an inode can release this process's POSIX record locks + // for that inode. + // Keep both source files open until SQLite closes its connection, including after an error. + let mut source_files = Vec::new(); + let result: anyhow::Result = async { + { + let mut handle = connection.lock_handle().await?; + // SAFETY: SQLx's guard prevents its worker from accessing this open connection. + // This db_config option accepts an int and an optional output pointer to an int. + let status = unsafe { + libsqlite3_sys::sqlite3_db_config( + handle.as_raw_handle().as_ptr(), + libsqlite3_sys::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, + /*onoff*/ 1, + std::ptr::null_mut::(), + ) + }; + ensure!( + status == libsqlite3_sys::SQLITE_OK, + "cannot disable source checkpoint on close: {status}" + ); + } + let mode: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&mut connection) + .await + .context( + "cannot access source database exclusively; close its SQLite clients on all hosts", + )?; + if mode == "delete" { + ensure!( + !regular_file_exists(&replacement.replacement_path())?, + "unfinished backup exists" + ); + return Ok(false); + } + ensure!( + mode == "wal", + "source database must be in WAL mode (found {mode})" + ); + // BEGIN EXCLUSIVE alone blocks only WAL writers. EXCLUSIVE locking_mode also blocks readers. + // The mode keeps the database lock after ROLLBACK. + sqlx::raw_sql("BEGIN EXCLUSIVE; ROLLBACK;") + .execute(&mut connection) + .await + .context( + "cannot lock source database exclusively; close its SQLite clients on all hosts", + )?; + + // A DELETE no-op must not open raw descriptors: closing them could release + // POSIX locks held by another pool in this process. + source_files.push(File::open(source)?); + #[cfg(unix)] + access::require_private_database(&source_files[0])?; + // The private directory protects WAL contents even on a shared /tmp. + let staging = tempfile::Builder::new() + .prefix("codex-nfs-migration-") + .tempdir_in(staging_dir) + .context("cannot create local staging directory")?; + let staged_database = staging.path().join("database.sqlite"); + + let mut wal_path = source.as_os_str().to_owned(); + wal_path.push("-wal"); + match File::open(wal_path) { + Ok(file) => source_files.push(file), + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error).context("cannot open source WAL"), + } + for (file, name) in source_files + .iter_mut() + .zip(["database.sqlite", "database.sqlite-wal"]) + { + let mut target = File::create(staging.path().join(name))?; + copy_buffered(file, &mut target).context("cannot stage source database and WAL")?; + } + let mut copy = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&staged_database) + .locking_mode(SqliteLockingMode::Exclusive) + .journal_mode(SqliteJournalMode::Delete) + .synchronous(SqliteSynchronous::Full) + .disable_statement_logging(), + ) + .await + .context("cannot convert staged database to DELETE")?; + let validated = validate_delete_database(&mut copy) + .await + .context("staged database failed validation"); + let closed = copy.close().await; + validated?; + closed?; + + replacement.prepare_backup_directory()?; + let mut temporary = tempfile::NamedTempFile::new_in(&replacement.backup_directory)?; + copy_buffered(&mut File::open(&staged_database)?, temporary.as_file_mut()) + .context("cannot write replacement copy")?; + // Use the open handle. Closing another source descriptor can release SQLite's locks. + temporary + .as_file() + .set_permissions(source_files[0].metadata()?.permissions())?; + #[cfg(unix)] + access::require_private_database(temporary.as_file())?; + temporary.as_file().sync_all()?; + temporary + .into_temp_path() + .persist_noclobber(replacement.replacement_path()) + .context("cannot save replacement copy")?; + File::open(&replacement.backup_directory)?.sync_all()?; + for file in &source_files { + file.sync_all()?; + } + replacement.move_original_to_backup()?; + Ok(true) + } + .await; + let closed = connection.close().await; + drop(source_files); + let changed = result?; + closed.context("cannot close source database. Keep clients stopped")?; + if changed { + replacement.install()?; + return Ok(NfsDbMigrationOutcome::Migrated { + backup_directory: replacement.backup_directory, + }); + } + Ok(NfsDbMigrationOutcome::AlreadyDelete) + } +} + +async fn validate_delete_database(connection: &mut SqliteConnection) -> anyhow::Result<()> { + let mode: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&mut *connection) + .await?; + ensure!( + mode == "delete", + "database did not enter DELETE journal mode" + ); + let integrity: String = sqlx::query_scalar("PRAGMA integrity_check(1)") + .fetch_one(connection) + .await + .context("database failed integrity check")?; + ensure!( + integrity == "ok", + "database failed integrity check: {integrity}" + ); + Ok(()) +} + +fn copy_buffered(source: &mut File, destination: &mut File) -> std::io::Result<()> { + // Use large reads to avoid the fallback to small reads for copy_file_range on NFSv4.1. + let mut buffer = vec![0; 1024 * 1024]; + loop { + let bytes = match source.read(&mut buffer) { + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + result => result?, + }; + if bytes == 0 { + return Ok(()); + } + destination.write_all(&buffer[..bytes])?; + } +} diff --git a/codex-rs/cli/src/migrate_nfs_db/access.rs b/codex-rs/state/src/nfs_migration/access.rs similarity index 100% rename from codex-rs/cli/src/migrate_nfs_db/access.rs rename to codex-rs/state/src/nfs_migration/access.rs diff --git a/codex-rs/cli/src/migrate_nfs_db/replacement.rs b/codex-rs/state/src/nfs_migration/replacement.rs similarity index 100% rename from codex-rs/cli/src/migrate_nfs_db/replacement.rs rename to codex-rs/state/src/nfs_migration/replacement.rs diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 58dd668469de..2ecd9592c69a 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -148,7 +148,6 @@ impl StateRuntime { let goals_path = sqlite.goals_db_path(); let memories_path = sqlite.memories_db_path(); let queue_path = sqlite.queue_db_path(); - let has_memories_v2 = tokio::fs::try_exists(sqlite.memories_v2_db_path()).await?; let pool = match sqlite .open_state_db(&state_migrator, telemetry_override) .await @@ -159,6 +158,7 @@ impl StateRuntime { return Err(err); } }; + let has_memories_v2 = tokio::fs::try_exists(sqlite.memories_v2_db_path()).await?; let logs_pool = match sqlite .open_logs_db(&logs_migrator, telemetry_override) .await diff --git a/codex-rs/state/src/sqlite.rs b/codex-rs/state/src/sqlite.rs index d68c1fa8e4c7..8719ac0ad88a 100644 --- a/codex-rs/state/src/sqlite.rs +++ b/codex-rs/state/src/sqlite.rs @@ -1,6 +1,6 @@ //! Shared SQLite connection configuration. //! New databases use DELETE on NFS and WAL elsewhere. -//! Writable pools preserve the journal and vacuum modes of initialized databases. +//! Writable pools preserve existing settings unless managed policy requires DELETE on NFS. //! Startup retries SQLITE_BUSY and reports other errors to callers. //! The configured logs directory is created and locked only when opening its database. @@ -32,11 +32,15 @@ use std::time::Instant; use tokio::sync::OnceCell; mod busy_retry; +mod connection; +mod nfs; mod validation; pub use busy_retry::SqlitePoolWriteExt; pub(crate) use busy_retry::SqliteWriteQueryExt; pub(crate) use busy_retry::retry_on_busy; +use connection::JournalModePolicy; +use connection::connect_once; use validation::SqliteQuickCheckManager; const DEFAULT_BUSY_TIMEOUT: Duration = Duration::from_secs(5); @@ -171,6 +175,7 @@ pub struct SqliteConfig { local_logs_dir: Option, is_nfs: Arc>, logs_is_nfs: Arc>, + enforce_delete_on_nfs: bool, quick_check_manager: SqliteQuickCheckManager, } @@ -179,6 +184,7 @@ pub struct SqliteConfig { impl PartialEq for SqliteConfig { fn eq(&self, other: &Self) -> bool { self.sqlite_home == other.sqlite_home + && self.enforce_delete_on_nfs == other.enforce_delete_on_nfs && self.local_logs_dir.as_ref().map(LocalLogsDir::path) == other.local_logs_dir.as_ref().map(LocalLogsDir::path) } @@ -187,18 +193,42 @@ impl PartialEq for SqliteConfig { impl Eq for SqliteConfig {} impl SqliteConfig { + #[deprecated( + note = "Use SqliteConfig::from_sqlite_home_with_nfs_enforcement with the managed unsafe_enforce_sqlite_journal_mode requirement, or SqliteConfig::new_for_testing in tests" + )] pub fn from_sqlite_home(sqlite_home: AbsolutePathBuf) -> Self { + Self::from_sqlite_home_with_nfs_enforcement( + sqlite_home, + /*enforce_delete_on_nfs*/ false, + ) + } + + /// Construct the configuration with the resolved `unsafe_enforce_sqlite_journal_mode` requirement. + pub fn from_sqlite_home_with_nfs_enforcement( + sqlite_home: AbsolutePathBuf, + enforce_delete_on_nfs: bool, + ) -> Self { Self { sqlite_home, local_logs_dir: None, is_nfs: Arc::new(OnceCell::new()), logs_is_nfs: Arc::new(OnceCell::new()), + enforce_delete_on_nfs, quick_check_manager: SqliteQuickCheckManager::new(), } } + /// When enforcement is enabled, migrate existing WAL databases on NFS before opening them. + pub fn enable_delete_mode_on_nfs(mut self, enabled: bool) -> Self { + self.enforce_delete_on_nfs = enabled; + self + } + pub fn new_for_testing(sqlite_home: AbsolutePathBuf) -> Self { - Self::from_sqlite_home(sqlite_home) + Self::from_sqlite_home_with_nfs_enforcement( + sqlite_home, + /*enforce_delete_on_nfs*/ false, + ) } pub fn home(&self) -> &Path { @@ -428,7 +458,16 @@ impl SqliteConfig { } else { probe_nfs(directory, &OnceCell::new()).await? }; - let journal_mode_policy = if is_nfs { + // Keep the migration lock through pool initialization so another managed startup + // cannot open the old inode or initialize a database during cutover. + let migration = if self.enforce_delete_on_nfs && is_nfs { + Some(nfs::prepare_database(self, path).await?) + } else { + None + }; + let journal_mode_policy = if migration.is_some() { + JournalModePolicy::RequireDelete + } else if is_nfs { JournalModePolicy::InitializeDelete } else { JournalModePolicy::InitializeWal @@ -506,6 +545,7 @@ impl SqliteConfig { } /// Open old logs for cleanup without creating, migrating, or rebuilding the database. + /// Skip legacy NFS logs when managed DELETE enforcement is active. pub(crate) async fn open_legacy_logs_for_cleanup(&self) -> anyhow::Result> { let Some(path) = self.legacy_logs_db_path() else { return Ok(None); @@ -513,6 +553,9 @@ impl SqliteConfig { if !tokio::fs::try_exists(&path).await? { return Ok(None); } + if self.enforce_delete_on_nfs && self.is_nfs().await? { + return Ok(None); + } let options = SqliteConnectOptions::new() .filename(path) .create_if_missing(false) @@ -539,105 +582,6 @@ impl SqliteConfig { } } -#[derive(Clone, Copy, Eq, PartialEq)] -enum JournalModePolicy { - InitializeWal, - InitializeDelete, -} - -async fn connect_once( - options: &SqliteConnectOptions, - journal_mode_policy: JournalModePolicy, -) -> Result { - // SQLx retries after_connect errors, eventually replacing them with PoolTimedOut. - // Return the first initialization error directly while opening this pool. - let (init_error_tx, mut init_error_rx) = tokio::sync::mpsc::channel(/*buffer*/ 1); - let pool_options = - SqlitePoolOptions::new() - .max_connections(5) - .after_connect(move |connection, _metadata| { - let init_error_tx = init_error_tx.clone(); - Box::pin(async move { - let result = async { - let journal: String = sqlx::query_scalar("PRAGMA journal_mode") - .fetch_one(&mut *connection) - .await?; - if journal == "delete" { - // If the filesystem honors sync requests, EXTRA syncs the directory - // after SQLite deletes a rollback journal. - sqlx::query("PRAGMA synchronous = EXTRA") - .execute(connection) - .await?; - } - Ok(()) - } - .await; - result.map_err(|error| match init_error_tx.try_send(error) { - // The opener owns the original error and cancels connection retries. - Ok(()) => Error::PoolClosed, - // After opening, lazy connections retain SQLx's normal error handling. - Err(error) => error.into_inner(), - }) - }) - }); - let pool = tokio::select! { - biased; - Some(error) = init_error_rx.recv() => Err(error), - result = pool_options.connect_with(options.clone()) => result, - }?; - // Database settings persist on disk. - // Initialize these settings once before callers can use the pool. - // Connections that open later need only settings that apply to each connection. - let initialized: Result<(), Error> = async { - let mut connection = pool.acquire().await?; - // Auto-vacuum alone can write a header to disk. - // If creation stops before the schema exists, startup must initialize the file. - let empty = - sqlx::query_scalar::<_, bool>("SELECT NOT EXISTS (SELECT 1 FROM sqlite_schema)") - .fetch_one(&mut *connection) - .await?; - if empty { - let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum") - .fetch_one(&mut *connection) - .await?; - if mode == 0 { - sqlx::query("PRAGMA auto_vacuum = INCREMENTAL") - .execute(&mut *connection) - .await?; - } - let pragma = match journal_mode_policy { - JournalModePolicy::InitializeWal => "PRAGMA journal_mode = WAL", - JournalModePolicy::InitializeDelete => "PRAGMA journal_mode = DELETE", - }; - sqlx::query(pragma).execute(&mut *connection).await?; - } - // Initialization or another process can change the journal mode after after_connect. - // Match this connection's synchronization setting to the resulting journal mode. - let journal: String = sqlx::query_scalar("PRAGMA journal_mode") - .fetch_one(&mut *connection) - .await?; - if journal == "wal" { - sqlx::query("PRAGMA synchronous = NORMAL") - .execute(&mut *connection) - .await?; - } else if journal == "delete" { - sqlx::query("PRAGMA synchronous = EXTRA") - .execute(&mut *connection) - .await?; - } - // Wait for the connection to return to the pool before startup requests it again. - // An asynchronous drop can otherwise cause the pool to open a second connection. - connection.return_to_pool().await; - Ok(()) - } - .await; - if let Err(error) = initialized { - pool.close().await; - return Err(error); - } - Ok(pool) -} - async fn probe_nfs(home: &Path, probe: &OnceCell) -> anyhow::Result { probe .get_or_try_init(|| async { diff --git a/codex-rs/state/src/sqlite/connection.rs b/codex-rs/state/src/sqlite/connection.rs new file mode 100644 index 000000000000..0d74e915325b --- /dev/null +++ b/codex-rs/state/src/sqlite/connection.rs @@ -0,0 +1,122 @@ +//! Initializes writable connections while preserving existing database settings. +//! Only empty databases adopt the requested journal mode; DELETE connections use EXTRA sync. +//! Managed DELETE requirements also reject other modes on every connection. + +#![expect( + clippy::disallowed_methods, + reason = "centralized SQLite connection initialization" +)] + +use sqlx::Error; +use sqlx::SqlitePool; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::sqlite::SqlitePoolOptions; + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(super) enum JournalModePolicy { + InitializeWal, + InitializeDelete, + RequireDelete, +} + +pub(super) async fn connect_once( + options: &SqliteConnectOptions, + journal_mode_policy: JournalModePolicy, +) -> Result { + // SQLx retries after_connect errors, eventually replacing them with PoolTimedOut. + // Return the first initialization error directly while opening this pool. + let (init_error_tx, mut init_error_rx) = tokio::sync::mpsc::channel(/*buffer*/ 1); + let pool_options = + SqlitePoolOptions::new() + .max_connections(5) + .after_connect(move |connection, _metadata| { + let init_error_tx = init_error_tx.clone(); + Box::pin(async move { + let result = async { + let journal: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&mut *connection) + .await?; + if journal_mode_policy == JournalModePolicy::RequireDelete + && journal != "delete" + { + return Err(Error::Protocol(format!( + "NFS database requires DELETE journaling, found {journal}" + ))); + } + if journal == "delete" { + // If the filesystem honors sync requests, EXTRA syncs the directory + // after SQLite deletes a rollback journal. + sqlx::query("PRAGMA synchronous = EXTRA") + .execute(connection) + .await?; + } + Ok(()) + } + .await; + result.map_err(|error| match init_error_tx.try_send(error) { + // The opener owns the original error and cancels connection retries. + Ok(()) => Error::PoolClosed, + // After opening, lazy connections retain SQLx's normal error handling. + Err(error) => error.into_inner(), + }) + }) + }); + let pool = tokio::select! { + biased; + Some(error) = init_error_rx.recv() => Err(error), + result = pool_options.connect_with(options.clone()) => result, + }?; + // Database settings persist on disk. + // Initialize these settings once before callers can use the pool. + // Connections that open later need only settings that apply to each connection. + let initialized: Result<(), Error> = async { + let mut connection = pool.acquire().await?; + // Auto-vacuum alone can write a header to disk. + // If creation stops before the schema exists, startup must initialize the file. + let empty = + sqlx::query_scalar::<_, bool>("SELECT NOT EXISTS (SELECT 1 FROM sqlite_schema)") + .fetch_one(&mut *connection) + .await?; + if empty { + let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum") + .fetch_one(&mut *connection) + .await?; + if mode == 0 { + sqlx::query("PRAGMA auto_vacuum = INCREMENTAL") + .execute(&mut *connection) + .await?; + } + let pragma = match journal_mode_policy { + JournalModePolicy::InitializeWal => "PRAGMA journal_mode = WAL", + JournalModePolicy::InitializeDelete | JournalModePolicy::RequireDelete => { + "PRAGMA journal_mode = DELETE" + } + }; + sqlx::query(pragma).execute(&mut *connection).await?; + } + // Initialization or another process can change the journal mode after after_connect. + // Match this connection's synchronization setting to the resulting journal mode. + let journal: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&mut *connection) + .await?; + if journal == "wal" { + sqlx::query("PRAGMA synchronous = NORMAL") + .execute(&mut *connection) + .await?; + } else if journal == "delete" { + sqlx::query("PRAGMA synchronous = EXTRA") + .execute(&mut *connection) + .await?; + } + // Wait for the connection to return to the pool before startup requests it again. + // An asynchronous drop can otherwise cause the pool to open a second connection. + connection.return_to_pool().await; + Ok(()) + } + .await; + if let Err(error) = initialized { + pool.close().await; + return Err(error); + } + Ok(pool) +} diff --git a/codex-rs/state/src/sqlite/nfs.rs b/codex-rs/state/src/sqlite/nfs.rs new file mode 100644 index 000000000000..1a0386462ff6 --- /dev/null +++ b/codex-rs/state/src/sqlite/nfs.rs @@ -0,0 +1,66 @@ +//! Migrates databases after the caller checks the managed policy and detects NFS. +//! Migration errors must not trigger corruption recovery that would discard a WAL backup. + +use super::SqliteConfig; +use crate::NfsDbMigration; +use crate::NfsDbMigrationOutcome; +use anyhow::Context; +use std::fs::TryLockError; +use std::path::Path; +use std::time::Duration; +use tokio::time::Instant; + +pub(super) async fn prepare_database( + sqlite: &SqliteConfig, + path: &Path, +) -> anyhow::Result { + // Replacement syncs the source directory, which must not be an empty relative parent. + let absolute_path = std::path::absolute(path)?; + let path = absolute_path.as_path(); + let home = path + .parent() + .context("database path has no parent directory")?; + // Concurrent managed startups can briefly hold this lock even after migration is complete. + let deadline = Instant::now() + super::DEFAULT_BUSY_TIMEOUT; + let mut migration = loop { + match NfsDbMigration::lock(home) { + Ok(migration) => break migration, + Err(error) + if matches!( + error.downcast_ref::(), + Some(TryLockError::WouldBlock) + ) && Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await; + } + Err(error) => return Err(error), + } + }; + // The primary runtime opens state first. Include lazy databases so startup also + // resumes interrupted migrations whose source path has not been reinstalled yet. + // A separate logs directory is checked and locked when its own database opens. + let paths = if path == sqlite.state_db_path() { + sqlite + .runtime_db_paths() + .into_iter() + .map(|db| db.path) + .filter(|path| path.parent() == Some(home)) + .collect() + } else { + vec![path.to_path_buf()] + }; + for path in paths { + let outcome = migration.migrate(&path, &std::env::temp_dir()).await.map_err(|error| { + // Do not expose a SQLite corruption error to automatic backup-and-rebuild recovery: + // an interrupted cutover must be resumed by the migration implementation itself. + anyhow::anyhow!( + "required NFS database migration failed for {}: {error:#}. Stop Codex instances on all hosts and rerun codex migrate-nfs-db", + path.display() + ) + })?; + if let NfsDbMigrationOutcome::Migrated { backup_directory } = outcome { + tracing::info!(database = %path.display(), backup = %backup_directory.display(), "Migrated NFS database to DELETE journaling"); + } + } + Ok(migration) +} diff --git a/codex-rs/state/src/sqlite_tests.rs b/codex-rs/state/src/sqlite_tests.rs index bb674101b53d..e6d0548304cf 100644 --- a/codex-rs/state/src/sqlite_tests.rs +++ b/codex-rs/state/src/sqlite_tests.rs @@ -163,19 +163,26 @@ async fn failed_sqlite_home_filesystem_probe_recovers_after_home_creation() -> a #[tokio::test] async fn open_read_write_pool_initializes_fresh_database_settings() -> anyhow::Result<()> { - for (nfs, partial_journal) in [ - (false, None), - (false, Some(SqliteJournalMode::Delete)), - (true, None), - (true, Some(SqliteJournalMode::Delete)), - (true, Some(SqliteJournalMode::Wal)), + for (enforced, nfs, partial_journal) in [ + (false, false, None), + (false, false, Some(SqliteJournalMode::Delete)), + (false, true, None), + (false, true, Some(SqliteJournalMode::Delete)), + (false, true, Some(SqliteJournalMode::Wal)), + (true, false, None), + (true, false, Some(SqliteJournalMode::Delete)), + (true, cfg!(unix), None), + (true, cfg!(unix), Some(SqliteJournalMode::Delete)), ] { let sqlite_home = crate::runtime::test_support::unique_temp_dir(); tokio::fs::create_dir_all(&sqlite_home).await?; let _cleanup = scopeguard::guard(sqlite_home.clone(), |sqlite_home| { let _ = std::fs::remove_dir_all(sqlite_home); }); - let sqlite = SqliteConfig::new_for_testing(sqlite_home.as_path().abs()); + let sqlite = SqliteConfig::from_sqlite_home_with_nfs_enforcement( + sqlite_home.as_path().abs(), + enforced, + ); sqlite.is_nfs.set(nfs)?; let path = sqlite.logs_db_path(); if let Some(journal_mode) = partial_journal { @@ -311,7 +318,9 @@ async fn open_read_write_pool_preserves_delete_under_write_lock() -> anyhow::Res let _cleanup = scopeguard::guard(sqlite_home.clone(), |sqlite_home| { let _ = std::fs::remove_dir_all(sqlite_home); }); - let sqlite = SqliteConfig::new_for_testing(sqlite_home.as_path().abs()); + let sqlite = SqliteConfig::new_for_testing(sqlite_home.as_path().abs()) + .enable_delete_mode_on_nfs(/*enabled*/ true); + sqlite.is_nfs.set(cfg!(unix))?; let database_path = sqlite.logs_db_path(); let existing_pool = SqlitePoolOptions::new() .max_connections(1) @@ -425,3 +434,142 @@ async fn open_read_write_pool_preserves_empty_database_lock_error() -> anyhow::R pool.close().await; Ok(()) } + +#[cfg(unix)] +#[tokio::test] +async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart() +-> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let home = tempfile::tempdir()?; + let original = SqliteConfig::new_for_testing(home.path().abs()); + let runtime = crate::StateRuntime::init(original.clone(), "test-provider".into()).await?; + runtime.checkpoint_backfill("before-migration").await?; + let before = runtime.get_backfill_state().await?; + runtime.close().await; + original.open_memories_v2_db().await?.close().await; + crate::open_thread_history_db(&original) + .await? + .close() + .await; + let mut backups = Vec::new(); + for db in original + .runtime_db_paths() + .into_iter() + .filter(|db| db.path.exists()) + { + std::fs::set_permissions(&db.path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?; + let name = db.path.file_name().unwrap().to_str().unwrap(); + backups.push(( + home.path() + .join(format!("{name}_wal_migration_backup")) + .join(name), + std::fs::read(&db.path)?, + )); + } + let sqlite = SqliteConfig::from_sqlite_home_with_nfs_enforcement( + home.path().abs(), + /*enforce_delete_on_nfs*/ true, + ); + sqlite.is_nfs.set(/*value*/ true)?; + let migrated = crate::StateRuntime::init(sqlite.clone(), "test-provider".into()).await?; + assert_eq!(migrated.get_backfill_state().await?, before); + migrated.checkpoint_backfill("after-migration").await?; + let after = migrated.get_backfill_state().await?; + migrated.close().await; + + // Simulate a crash after moving the original but before reinstalling a lazy database. + let lazy = sqlite.memories_v2_db_path(); + let name = lazy.file_name().unwrap().to_str().unwrap(); + std::fs::rename( + &lazy, + home.path() + .join(format!("{name}_wal_migration_backup/delete.sqlite")), + )?; + let restarted = crate::StateRuntime::init(sqlite.clone(), "test-provider".into()).await?; + assert_eq!(restarted.get_backfill_state().await?, after); + restarted.close().await; + // Include lazily created databases: they must also start in DELETE mode. + for db in sqlite.runtime_db_paths() { + let pool = sqlite.open_read_write_pool(&db.path).await?; + let settings: (String, i64) = sqlx::query_as( + "SELECT journal_mode, synchronous FROM pragma_journal_mode, pragma_synchronous", + ) + .fetch_one(&pool) + .await?; + assert_eq!(settings, ("delete".into(), 3)); + pool.close().await; + } + for (path, bytes) in backups { + assert_eq!(std::fs::read(path)?, bytes); + } + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn required_nfs_migration_refuses_active_wal_and_retries_after_close() -> anyhow::Result<()> { + use std::future::Future; + use std::os::unix::fs::PermissionsExt; + use std::task::Poll; + + let home = tempfile::tempdir()?; + let logs_home = home.path().join("local-logs"); + let sqlite = + SqliteConfig::new_for_testing(home.path().abs()).with_local_logs_home(logs_home.clone()); + let logs = sqlite.open_read_write_pool(&sqlite.logs_db_path()).await?; + sqlx::query("CREATE TABLE existing_logs (id INTEGER PRIMARY KEY)") + .execute(&logs) + .await?; + let path = sqlite.state_db_path(); + let pool = sqlite.open_read_write_pool(&path).await?; + sqlx::raw_sql("CREATE TABLE entries(id); INSERT INTO entries VALUES(42);") + .execute(&pool) + .await?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?; + let required = SqliteConfig::new_for_testing(home.path().abs()) + .with_local_logs_home(logs_home) + .enable_delete_mode_on_nfs(/*enabled*/ true); + required.is_nfs.set(/*value*/ true)?; + required.logs_is_nfs.set(/*value*/ false)?; + let error = required.open_read_write_pool(&path).await.unwrap_err(); + assert!( + error + .to_string() + .contains("required NFS database migration failed"), + "{error:#}" + ); + assert!(!crate::is_sqlite_corruption_error(&error)); + pool.close().await; + let migration_lock = crate::NfsDbMigration::lock(home.path())?; + let mut opening = Box::pin(required.open_read_write_pool(&path)); + std::future::poll_fn(|cx| { + assert!(opening.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + drop(migration_lock); + let migrated = opening.await?; + let rows: Vec = sqlx::query_scalar("SELECT id FROM entries") + .fetch_all(&migrated) + .await?; + assert_eq!(rows, vec![42]); + migrated.close().await; + // An active local logs database must remain in WAL while the NFS state database migrates. + let journal: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&logs) + .await?; + assert_eq!(journal, "wal"); + logs.close().await; + // Old versions may create legacy WAL logs after startup. Managed cleanup must not write them. + let legacy = sqlite + .open_read_write_pool(&sqlite.legacy_logs_db_path().unwrap()) + .await?; + assert!(required.open_legacy_logs_for_cleanup().await?.is_none()); + let journal: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&legacy) + .await?; + assert_eq!(journal, "wal"); + legacy.close().await; + Ok(()) +} diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index afc615597429..47792f00acd6 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -338,7 +338,10 @@ async fn new_config( agent_max_depth: 1, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), - sqlite: SqliteConfig::from_sqlite_home(codex_home.clone()), + sqlite: SqliteConfig::from_sqlite_home_with_nfs_enforcement( + codex_home.clone(), + /*enforce_delete_on_nfs*/ false, + ), log_dir: codex_home.join("log").to_path_buf(), codex_home, history: History::default(), diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 01a7802d0636..01842c2f1e4f 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -955,6 +955,7 @@ interrupt_message = false cli_auth_credentials_store: None, chatgpt_base_url: None, sqlite_home: Some(sqlite_home), + unsafe_enforce_sqlite_journal_mode: None, log_dir: Some(log_dir), model_catalog_json: Some(model_catalog_json), model_provider: None, From ca8b7a0737fde86940548a3d1d76b46ce382c6f8 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sun, 25 Oct 2026 16:19:42 +0000 Subject: [PATCH 048/287] Allow NFS database migration with shared file permissions (#57226) ## Why NFS database migration rejected files with group or other access. Allow accessible databases to migrate while making the effects on file access explicit. ## What changed - Preserve file mode bits during migration while requiring owner read and write permissions on replacement files. - Before CLI consent, disclose that replacements belong to the current user and do not copy the original owner, group, or extended ACLs. Require explicit confirmation for files with permissions other than `0600`. - Warn at Linux TUI startup when NFS databases need migration and permission confirmation. Inspect journal mode without accessing WAL shared memory or releasing another connection's locks. - Allow five seconds to acquire the exclusive database lock during migration. ## Testing Add CLI coverage for consent, access disclosures, and migration of `0644` files. Extend state tests for permission preservation, rejection of missing owner access, and interrupted migration recovery; snapshot the startup warning. GitOrigin-RevId: 2d9c3663172c3e631a787dc1dc76db95c39e6aa1 --- codex-rs/Cargo.lock | 1 - codex-rs/cli/src/migrate_nfs_db.rs | 29 +++- codex-rs/cli/tests/migrate_nfs_db.rs | 85 +++++++++++ codex-rs/config/src/config_requirements.rs | 2 + codex-rs/state/Cargo.toml | 1 - codex-rs/state/src/nfs_migration.rs | 115 ++++++++++++++- codex-rs/state/src/nfs_migration/access.rs | 133 ------------------ .../state/src/nfs_migration/replacement.rs | 10 +- codex-rs/state/src/sqlite.rs | 1 + codex-rs/state/src/sqlite_tests.rs | 29 +++- ...arnings__tests__nfs_migration_warning.snap | 7 + codex-rs/tui/src/app/startup.rs | 8 ++ codex-rs/tui/src/app/startup_warnings.rs | 38 ++++- .../tui/src/app/startup_warnings_tests.rs | 16 +++ 14 files changed, 318 insertions(+), 157 deletions(-) create mode 100644 codex-rs/cli/tests/migrate_nfs_db.rs delete mode 100644 codex-rs/state/src/nfs_migration/access.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__startup_warnings__tests__nfs_migration_warning.snap create mode 100644 codex-rs/tui/src/app/startup_warnings_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 12a8a8bb0fae..a5fb37817250 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4802,7 +4802,6 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "file-id", - "libc", "libsqlite3-sys", "log", "pretty_assertions", diff --git a/codex-rs/cli/src/migrate_nfs_db.rs b/codex-rs/cli/src/migrate_nfs_db.rs index 02c296d4f067..b9ab115809ab 100644 --- a/codex-rs/cli/src/migrate_nfs_db.rs +++ b/codex-rs/cli/src/migrate_nfs_db.rs @@ -42,10 +42,6 @@ pub(crate) async fn run( eprintln!( "Stop all Codex instances on every host that uses these databases.\nKeep them stopped until the migration completes successfully." ); - ensure!( - crate::confirm("Continue? [y/N]:")?, - "Migration cancelled. No databases were changed." - ); let config = ConfigBuilder::default() .cli_overrides( config_overrides @@ -63,6 +59,31 @@ pub(crate) async fn run( println!("No databases found."); return Ok(()); } + let mut needs_permission_confirmation = false; + for database in sqlite.runtime_db_paths() { + if skip_logs && database.path == sqlite.logs_db_path() { + continue; + } + needs_permission_confirmation |= + NfsDbMigration::needs_permission_confirmation(&database.path)?; + } + if needs_permission_confirmation { + eprintln!( + "Some database files have permissions other than 0600.\nMigration preserves file mode bits, including group and other access." + ); + } + eprintln!( + "Replacement files belong to you; the original owner, group, and extended ACLs are not copied.\nThis can remove another user's access even when file permissions are 0600.\nOriginal files remain in a private backup directory.\nYou need write access to the database directory and its migration lock." + ); + let prompt = if needs_permission_confirmation { + "Migrate databases, including files with permissions other than 0600? [y/N]:" + } else { + "Continue? [y/N]:" + }; + ensure!( + crate::confirm(prompt)?, + "Migration cancelled. No databases were changed." + ); let mut migration = NfsDbMigration::lock(sqlite.home())?; for database in sqlite.runtime_db_paths() { if skip_logs && database.path == sqlite.logs_db_path() { diff --git a/codex-rs/cli/tests/migrate_nfs_db.rs b/codex-rs/cli/tests/migrate_nfs_db.rs new file mode 100644 index 000000000000..326bbad0840d --- /dev/null +++ b/codex-rs/cli/tests/migrate_nfs_db.rs @@ -0,0 +1,85 @@ +//! Exercise consent and file permissions through the migration CLI. + +#![cfg(unix)] + +use anyhow::Result; +use codex_state::SqliteConfig; +use predicates::prelude::PredicateBooleanExt; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use std::os::unix::fs::PermissionsExt; + +#[tokio::test] +async fn migration_requires_consent_and_preserves_shared_database_mode() -> Result<()> { + let home = tempfile::tempdir()?; + let sqlite = SqliteConfig::new_for_testing(home.path().to_path_buf().try_into()?); + let path = sqlite.state_db_path(); + let pool = sqlite.open_read_write_pool(&path).await?; + sqlx::raw_sql("CREATE TABLE entries (id INTEGER); INSERT INTO entries VALUES (42)") + .execute(&pool) + .await?; + pool.close().await; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?; + let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + let declined = command + .env("CODEX_HOME", home.path()) + .env(codex_state::SQLITE_HOME_ENV, home.path()) + .arg("migrate-nfs-db") + .write_stdin("n\n") + .assert() + .failure() + .stderr(contains("permissions other than 0600").not()) + .stderr(contains("Continue? [y/N]")); + let stderr = String::from_utf8_lossy(&declined.get_output().stderr); + let disclosure = stderr + .find("the original owner, group, and extended ACLs are not copied") + .unwrap(); + let consent = stderr.find("Continue? [y/N]").unwrap(); + assert!( + disclosure < consent, + "ACL loss must be disclosed before consent, including for 0600 files" + ); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(/*mode*/ 0o644))?; + let before = std::fs::read(&path)?; + + let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + command + .env("CODEX_HOME", home.path()) + .env(codex_state::SQLITE_HOME_ENV, home.path()) + .arg("migrate-nfs-db") + .write_stdin("n\n") + .assert() + .failure() + .stderr(contains("permissions other than 0600")) + .stderr(contains("No databases were changed")); + assert_eq!(std::fs::read(&path)?, before); + + let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + command + .env("CODEX_HOME", home.path()) + .env(codex_state::SQLITE_HOME_ENV, home.path()) + .arg("migrate-nfs-db") + .write_stdin("y\n") + .assert() + .success() + .stdout(contains("Migration completed successfully")); + let pool = sqlite + .open_read_only_pool(&path, /*busy_timeout*/ None) + .await?; + let mode: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&pool) + .await?; + let rows: Vec = sqlx::query_scalar("SELECT id FROM entries") + .fetch_all(&pool) + .await?; + assert_eq!( + ( + mode, + rows, + std::fs::metadata(&path)?.permissions().mode() & 0o777 + ), + ("delete".to_string(), vec![42], 0o644) + ); + pool.close().await; + Ok(()) +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index e64105884441..e83fba9de4ea 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1052,6 +1052,8 @@ pub struct ConfigRequirementsToml { pub chatgpt_base_url: Option, pub sqlite_home: Option, /// Require migration of existing WAL databases on NFS before opening writable pools. + /// Unsafe: other clients on all hosts must remain stopped during replacement. + /// Allows accessible shared files; mode bits are preserved, but ownership and ACLs are not. pub unsafe_enforce_sqlite_journal_mode: Option, pub log_dir: Option, pub model_catalog_json: Option, diff --git a/codex-rs/state/Cargo.toml b/codex-rs/state/Cargo.toml index bc32feccab09..e03dcd3d42fd 100644 --- a/codex-rs/state/Cargo.toml +++ b/codex-rs/state/Cargo.toml @@ -12,7 +12,6 @@ codex-history = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } file-id = { workspace = true } -libc = { workspace = true } libsqlite3-sys = { workspace = true } log = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/state/src/nfs_migration.rs b/codex-rs/state/src/nfs_migration.rs index 160eabd046b4..f89df656c5f8 100644 --- a/codex-rs/state/src/nfs_migration.rs +++ b/codex-rs/state/src/nfs_migration.rs @@ -1,6 +1,8 @@ //! Converts WAL databases to DELETE mode while preserving recoverable WAL backups. //! Callers serialize startup and migration with the directory lock. Other SQLite clients //! must remain stopped during replacement; NFS must enforce cross-client POSIX locks. +//! Migration requires write access to the directory and its persistent migration lock. +//! Relaxed file permissions do not establish cross-user database sharing. use anyhow::Context; use anyhow::ensure; @@ -19,8 +21,6 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; -#[cfg(unix)] -mod access; mod replacement; use replacement::Recovery; @@ -40,6 +40,76 @@ pub enum NfsDbMigrationOutcome { } impl NfsDbMigration { + /// Inspect the on-disk journal mode without accessing WAL shared memory. + /// Use SQLite's descriptor so closing it cannot release another connection's locks. + #[expect( + clippy::disallowed_methods, + reason = "NFS inspection needs an immutable connection without shared memory" + )] + pub async fn needs_migration(source: &Path) -> anyhow::Result { + if !regular_file_exists(source)? { + let replacement = Replacement::new(source)?; + return Ok(regular_file_exists(&replacement.backup_database)? + || regular_file_exists(&replacement.replacement_path())?); + } + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(source) + .read_only(true) + .immutable(true) + .disable_statement_logging(), + ) + .await?; + let inspected: anyhow::Result = async { + let mut handle = connection.lock_handle().await?; + let mut file: *mut libsqlite3_sys::sqlite3_file = std::ptr::null_mut(); + // SAFETY: SQLx's guard excludes its worker. FILE_POINTER writes a borrowed + // sqlite3_file pointer, valid while this connection and guard remain alive. + let status = unsafe { + libsqlite3_sys::sqlite3_file_control( + handle.as_raw_handle().as_ptr(), + c"main".as_ptr(), + libsqlite3_sys::SQLITE_FCNTL_FILE_POINTER, + (&raw mut file).cast(), + ) + }; + ensure!( + status == libsqlite3_sys::SQLITE_OK && !file.is_null(), + "cannot inspect database header" + ); + // SAFETY: FILE_POINTER returned a live file owned by the guarded connection. + let methods = unsafe { (*file).pMethods }; + ensure!(!methods.is_null(), "database VFS has no file methods"); + let mut header = [0u8; 20]; + // SAFETY: the built-in file VFS supplies xRead; the guarded file is live, + // and the buffer has space for exactly the requested 20 bytes. + let status = unsafe { + let read = (*methods).xRead.context("database VFS cannot read")?; + read( + file, + header.as_mut_ptr().cast(), + /*iAmt*/ 20, + /*iOfst*/ 0, + ) + }; + ensure!( + status == libsqlite3_sys::SQLITE_OK, + "cannot read database header: {status}" + ); + ensure!( + &header[..16] == b"SQLite format 3\0", + "invalid database header" + ); + // SQLite file-format read/write versions are 1 for rollback and 2 for WAL. + Ok(header[18] == 2 || header[19] == 2) + } + .await; + let closed = connection.close().await; + let needed = inspected?; + closed?; + Ok(needed) + } + pub fn lock(sqlite_home: &Path) -> anyhow::Result { ensure!( cfg!(unix), @@ -57,8 +127,34 @@ impl NfsDbMigration { Ok(Self { _lock: lock }) } + /// Whether source or recovery files have permission bits other than 0600. + /// Only inspect metadata: opening WAL databases can create shared-memory files. + pub fn needs_permission_confirmation(source: &Path) -> anyhow::Result { + let replacement = Replacement::new(source)?; + for path in [ + source.to_path_buf(), + replacement.backup_database.clone(), + replacement.replacement_path(), + ] { + if !regular_file_exists(&path)? { + continue; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::metadata(path)?.permissions().mode() & 0o777 != 0o600 { + return Ok(true); + } + } + } + Ok(false) + } + /// Convert an existing WAL database or resume an interrupted replacement. /// All other clients must remain stopped until this returns successfully. + /// Callers must obtain consent or require unsafe migration through managed policy. + /// The replacement preserves mode bits, but belongs to the current user and does not + /// copy the original owner, group, or extended ACLs. #[expect( clippy::disallowed_methods, reason = "Migration needs a dedicated exclusive connection, not a runtime pool" @@ -93,7 +189,7 @@ impl NfsDbMigration { // SQLite then locks the database file without shared memory across NFS client kernels. .locking_mode(SqliteLockingMode::Exclusive) .synchronous(SqliteSynchronous::Full) - .busy_timeout(Duration::ZERO) + .busy_timeout(Duration::from_secs(/*secs*/ 5)) .disable_statement_logging(); let mut connection = SqliteConnection::connect_with(&options).await.context( "cannot open source database exclusively; close its SQLite clients on all hosts", @@ -150,8 +246,15 @@ impl NfsDbMigration { // A DELETE no-op must not open raw descriptors: closing them could release // POSIX locks held by another pool in this process. source_files.push(File::open(source)?); + let source_permissions = source_files[0].metadata()?.permissions(); #[cfg(unix)] - access::require_private_database(&source_files[0])?; + { + use std::os::unix::fs::PermissionsExt; + ensure!( + source_permissions.mode() & 0o600 == 0o600, + "database owner permissions must include read and write so the replacement remains accessible to its new owner" + ); + } // The private directory protects WAL contents even on a shared /tmp. let staging = tempfile::Builder::new() .prefix("codex-nfs-migration-") @@ -197,9 +300,7 @@ impl NfsDbMigration { // Use the open handle. Closing another source descriptor can release SQLite's locks. temporary .as_file() - .set_permissions(source_files[0].metadata()?.permissions())?; - #[cfg(unix)] - access::require_private_database(temporary.as_file())?; + .set_permissions(source_permissions)?; temporary.as_file().sync_all()?; temporary .into_temp_path() diff --git a/codex-rs/state/src/nfs_migration/access.rs b/codex-rs/state/src/nfs_migration/access.rs deleted file mode 100644 index 46b57af08007..000000000000 --- a/codex-rs/state/src/nfs_migration/access.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Reject databases with access permissions that replacement cannot preserve. -//! Accept only files owned by the current user with no group or other access. - -use anyhow::Context; -use anyhow::ensure; -use std::fs::File; -use std::os::unix::fs::MetadataExt; - -pub(super) fn require_private_database(file: &File) -> anyhow::Result<()> { - let metadata = file.metadata()?; - // SAFETY: geteuid has no preconditions. - let user = unsafe { libc::geteuid() }; - ensure!( - metadata.uid() == user, - "database must be owned by the current user" - ); - ensure!( - metadata.mode() & 0o077 == 0, - "shared database access is not supported: group and other permissions must be absent" - ); - check_acl(file).context("cannot verify private database access") -} - -#[cfg(target_os = "linux")] -fn check_acl(file: &File) -> anyhow::Result<()> { - use std::os::fd::AsRawFd; - - // POSIX ACL grants are limited by the group mode bits checked above. - // NFSv4 named entries can grant access independently of those bits. - let mut buffer = vec![0u8; 65536]; - // SAFETY: The descriptor is open. The name and writable buffer are valid for this call. - let size = unsafe { - libc::fgetxattr( - file.as_raw_fd(), - c"system.nfs4_acl".as_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - ) - }; - if size < 0 { - let error = std::io::Error::last_os_error(); - return match error.raw_os_error() { - Some(libc::ENODATA | libc::EOPNOTSUPP) => Ok(()), - _ => Err(error.into()), - }; - } - let mut bytes = &buffer[..size as usize]; - let read_word = |bytes: &mut &[u8]| -> anyhow::Result { - let (word, rest) = bytes - .split_at_checked(/*mid*/ 4) - .context("invalid NFSv4 ACL")?; - *bytes = rest; - Ok(u32::from_be_bytes(word.try_into()?)) - }; - let entries = read_word(&mut bytes)?; - for _ in 0..entries { - let kind = read_word(&mut bytes)?; - let _flags = read_word(&mut bytes)?; - let permissions = read_word(&mut bytes)?; - let length = read_word(&mut bytes)? as usize; - let (name, rest) = bytes - .split_at_checked(length) - .context("invalid NFSv4 ACL")?; - ensure!( - matches!(name, b"OWNER@" | b"GROUP@" | b"EVERYONE@"), - "databases with named NFSv4 ACL entries are not supported" - ); - ensure!(kind <= 1, "unsupported NFSv4 ACL entry type"); - // Other users can read attributes and ACLs, and synchronize. Reject all other grants. - const READ_ATTRIBUTES: u32 = 0x80; - const READ_ACL: u32 = 0x20000; - const SYNCHRONIZE: u32 = 0x100000; - ensure!( - kind != 0 - || name == b"OWNER@" - || permissions & !(READ_ATTRIBUTES | READ_ACL | SYNCHRONIZE) == 0, - "shared database access through an NFSv4 ACL is not supported" - ); - bytes = rest - .get((4 - length % 4) % 4..) - .context("invalid NFSv4 ACL padding")?; - } - ensure!(bytes.is_empty(), "invalid NFSv4 ACL trailing data"); - Ok(()) -} - -#[cfg(target_os = "macos")] -fn check_acl(file: &File) -> anyhow::Result<()> { - use std::ffi::c_void; - use std::os::fd::AsRawFd; - - unsafe extern "C" { - fn acl_get_fd(fd: libc::c_int) -> *mut c_void; - fn acl_get_entry( - acl: *mut c_void, - entry_id: libc::c_int, - entry: *mut *mut c_void, - ) -> libc::c_int; - fn acl_free(acl: *mut c_void) -> libc::c_int; - } - // SAFETY: The descriptor is open. The returned ACL remains valid until acl_free. - let acl = unsafe { acl_get_fd(file.as_raw_fd()) }; - if acl.is_null() { - let error = std::io::Error::last_os_error(); - return match error.raw_os_error() { - // ENOENT means the open file has no extended ACL. - Some(libc::ENOENT | libc::ENOTSUP) => Ok(()), - _ => Err(error.into()), - }; - } - let mut entry = std::ptr::null_mut(); - // SAFETY: The ACL and output pointer are valid. Zero selects ACL_FIRST_ENTRY on macOS. - let status = unsafe { - acl_get_entry(acl, /*entry_id*/ 0, &mut entry) - }; - let error = std::io::Error::last_os_error(); - // SAFETY: This call releases the ACL returned by acl_get_fd exactly once. - unsafe { acl_free(acl) }; - ensure!( - status != 0, - "databases with extended macOS ACLs are not supported" - ); - ensure!( - error.raw_os_error() == Some(libc::EINVAL), - "cannot read database ACL: {error}" - ); - Ok(()) -} - -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -fn check_acl(_file: &File) -> anyhow::Result<()> { - anyhow::bail!("database ACL checks are not supported on this operating system") -} diff --git a/codex-rs/state/src/nfs_migration/replacement.rs b/codex-rs/state/src/nfs_migration/replacement.rs index 09c3b71f6ffe..2e856426da55 100644 --- a/codex-rs/state/src/nfs_migration/replacement.rs +++ b/codex-rs/state/src/nfs_migration/replacement.rs @@ -29,7 +29,7 @@ pub(super) enum Recovery { pub(super) struct Replacement { source: PathBuf, pub(super) backup_directory: PathBuf, - backup_database: PathBuf, + pub(super) backup_database: PathBuf, } impl Replacement { @@ -156,14 +156,6 @@ impl Replacement { let backup_exists = regular_file_exists(&self.backup_database)?; let replacement_exists = regular_file_exists(&self.replacement_path())?; let source_exists = regular_file_exists(&self.source)?; - #[cfg(unix)] - if backup_exists { - super::access::require_private_database(&File::open(&self.backup_database)?)?; - } - #[cfg(unix)] - if replacement_exists { - super::access::require_private_database(&File::open(self.replacement_path())?)?; - } // No original files moved yet. The caller must lock the source and rebuild the copy. if !backup_exists { for suffix in SIDECARS { diff --git a/codex-rs/state/src/sqlite.rs b/codex-rs/state/src/sqlite.rs index 8719ac0ad88a..e8bfe8beef9a 100644 --- a/codex-rs/state/src/sqlite.rs +++ b/codex-rs/state/src/sqlite.rs @@ -219,6 +219,7 @@ impl SqliteConfig { } /// When enforcement is enabled, migrate existing WAL databases on NFS before opening them. + /// Other clients on all hosts must remain stopped during migration. pub fn enable_delete_mode_on_nfs(mut self, enabled: bool) -> Self { self.enforce_delete_on_nfs = enabled; self diff --git a/codex-rs/state/src/sqlite_tests.rs b/codex-rs/state/src/sqlite_tests.rs index e6d0548304cf..968f54ef00e8 100644 --- a/codex-rs/state/src/sqlite_tests.rs +++ b/codex-rs/state/src/sqlite_tests.rs @@ -452,13 +452,24 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart .await? .close() .await; + let state_path = original.state_db_path(); + assert!(crate::NfsDbMigration::needs_migration(&state_path).await?); + let bytes = std::fs::read(&state_path)?; + std::fs::set_permissions(&state_path, std::fs::Permissions::from_mode(/*mode*/ 0o060))?; + let staging = tempfile::tempdir()?; + let result = crate::NfsDbMigration::lock(home.path())? + .migrate(&state_path, staging.path()) + .await; + std::fs::set_permissions(&state_path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?; + assert!(result.is_err()); + assert_eq!(std::fs::read(&state_path)?, bytes); let mut backups = Vec::new(); for db in original .runtime_db_paths() .into_iter() .filter(|db| db.path.exists()) { - std::fs::set_permissions(&db.path, std::fs::Permissions::from_mode(/*mode*/ 0o600))?; + std::fs::set_permissions(&db.path, std::fs::Permissions::from_mode(/*mode*/ 0o644))?; let name = db.path.file_name().unwrap().to_str().unwrap(); backups.push(( home.path() @@ -472,11 +483,15 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart /*enforce_delete_on_nfs*/ true, ); sqlite.is_nfs.set(/*value*/ true)?; + assert!(crate::NfsDbMigration::needs_permission_confirmation( + &sqlite.state_db_path() + )?); let migrated = crate::StateRuntime::init(sqlite.clone(), "test-provider".into()).await?; assert_eq!(migrated.get_backfill_state().await?, before); migrated.checkpoint_backfill("after-migration").await?; let after = migrated.get_backfill_state().await?; migrated.close().await; + assert!(!crate::NfsDbMigration::needs_migration(&state_path).await?); // Simulate a crash after moving the original but before reinstalling a lazy database. let lazy = sqlite.memories_v2_db_path(); @@ -486,6 +501,8 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart home.path() .join(format!("{name}_wal_migration_backup/delete.sqlite")), )?; + assert!(crate::NfsDbMigration::needs_permission_confirmation(&lazy)?); + assert!(crate::NfsDbMigration::needs_migration(&lazy).await?); let restarted = crate::StateRuntime::init(sqlite.clone(), "test-provider".into()).await?; assert_eq!(restarted.get_backfill_state().await?, after); restarted.close().await; @@ -499,6 +516,15 @@ async fn required_nfs_migration_preserves_runtime_data_and_backups_after_restart .await?; assert_eq!(settings, ("delete".into(), 3)); pool.close().await; + if backups + .iter() + .any(|(backup, _)| backup.file_name() == db.path.file_name()) + { + assert_eq!( + std::fs::metadata(&db.path)?.permissions().mode() & 0o777, + 0o644 + ); + } } for (path, bytes) in backups { assert_eq!(std::fs::read(path)?, bytes); @@ -532,6 +558,7 @@ async fn required_nfs_migration_refuses_active_wal_and_retries_after_close() -> .enable_delete_mode_on_nfs(/*enabled*/ true); required.is_nfs.set(/*value*/ true)?; required.logs_is_nfs.set(/*value*/ false)?; + assert!(crate::NfsDbMigration::needs_migration(&path).await?); let error = required.open_read_write_pool(&path).await.unwrap_err(); assert!( error diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__startup_warnings__tests__nfs_migration_warning.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__startup_warnings__tests__nfs_migration_warning.snap new file mode 100644 index 000000000000..e2c88f96e3ef --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__startup_warnings__tests__nfs_migration_warning.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/startup_warnings_tests.rs +expression: text +--- +⚠ Some Codex SQLite files on NFS have permissions other than 0600. Migration + preserves these permissions and requires your confirmation. Before running + `codex migrate-nfs-db`, stop Codex on every host that uses these databases. diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index ba1c01279be2..c8143be2da57 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -944,6 +944,14 @@ impl App { history_cell::StartupWarningsCell::new(vec![warning]), ))); } + // Automatic NFS detection is Linux-only; manual migration supports Unix. + #[cfg(target_os = "linux")] + if !app_server_target.uses_remote_workspace() { + tokio::spawn(startup_warnings::warn_about_nfs_migration( + config.sqlite_config().clone(), + app_event_tx.clone(), + )); + } let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let runtime_keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap).map_err(|err| { diff --git a/codex-rs/tui/src/app/startup_warnings.rs b/codex-rs/tui/src/app/startup_warnings.rs index 13ee59988a9e..2d7751b08a28 100644 --- a/codex-rs/tui/src/app/startup_warnings.rs +++ b/codex-rs/tui/src/app/startup_warnings.rs @@ -1,4 +1,4 @@ -//! Coalesce retained startup diagnostics without adding live transcript rows or forcing reflow. +//! Detect Linux NFS migration permission warnings and coalesce retained startup diagnostics. use super::*; use crate::history_cell::StartupWarningsCell; @@ -62,3 +62,39 @@ impl App { tui.frame_requester().schedule_frame(); } } + +#[cfg(target_os = "linux")] +const NFS_MIGRATION_WARNING: &str = "Some Codex SQLite files on NFS have permissions other than 0600. Migration preserves these permissions and requires your confirmation. Before running `codex migrate-nfs-db`, stop Codex on every host that uses these databases."; + +#[cfg(target_os = "linux")] +pub(super) async fn warn_about_nfs_migration( + sqlite: codex_state::SqliteConfig, + app_event_tx: AppEventSender, +) { + let inspection: anyhow::Result = async { + if !sqlite.is_nfs().await? { + return Ok(false); + } + for database in sqlite.runtime_db_paths() { + if database.path.parent() == Some(sqlite.home()) + && codex_state::NfsDbMigration::needs_permission_confirmation(&database.path)? + && codex_state::NfsDbMigration::needs_migration(&database.path).await? + { + return Ok(true); + } + } + Ok(false) + } + .await; + match inspection { + Ok(true) => app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + StartupWarningsCell::new(vec![NFS_MIGRATION_WARNING.to_string()]), + ))), + Ok(false) => {} + Err(error) => tracing::debug!(%error, "Could not inspect NFS database permissions"), + } +} + +#[cfg(all(test, target_os = "linux"))] +#[path = "startup_warnings_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/startup_warnings_tests.rs b/codex-rs/tui/src/app/startup_warnings_tests.rs new file mode 100644 index 000000000000..5406576664ab --- /dev/null +++ b/codex-rs/tui/src/app/startup_warnings_tests.rs @@ -0,0 +1,16 @@ +#![cfg(test)] + +use super::*; +use crate::history_cell::HistoryCell; + +#[test] +fn nfs_migration_warning() { + let cell = StartupWarningsCell::new(vec![NFS_MIGRATION_WARNING.to_string()]); + let lines = cell.transcript_lines(/*width*/ 80); + let text = lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + insta::assert_snapshot!(text); +} From 3047741b63abd7543ed78f0f8e0f703b0125ceea Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 25 Oct 2026 21:49:04 +0000 Subject: [PATCH 049/287] Preserve acknowledged permissions when resuming and forking threads (#57227) ## Why A running turn can record stale permissions after the thread accepts a settings update. Resuming or forking from that history should preserve the accepted approval and permission settings. ## What changed - Prefer settings snapshots owned by the source thread over stale turn contexts, including later legacy updates associated with that thread's checkpoint. - Restore unnamed permission profiles from attributable settings updates while honoring explicit request overrides. Avoid restoring raw permission grants from history without an identifiable owner. - Share permission restoration logic between resume and fork. ## Testing Add regression coverage for stale turn contexts, legacy updates across thread ownership boundaries, and unnamed permission restoration on resume and fork. GitOrigin-RevId: 450bc1688be78c7ec685f6dc56bcc5e5dd2d130a --- .../persisted_resume_settings.rs | 70 +++++++++-- .../persisted_resume_settings_tests.rs | 86 ++++++++++++- .../request_processors/thread_processor.rs | 98 +++++++-------- .../app-server/tests/suite/v2/thread_fork.rs | 16 ++- .../tests/suite/v2/thread_resume.rs | 113 +++++++++++++++++- 5 files changed, 318 insertions(+), 65 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/persisted_resume_settings.rs b/codex-rs/app-server/src/request_processors/persisted_resume_settings.rs index 9c4c3f18ad10..a9747d58abe4 100644 --- a/codex-rs/app-server/src/request_processors/persisted_resume_settings.rs +++ b/codex-rs/app-server/src/request_processors/persisted_resume_settings.rs @@ -1,5 +1,7 @@ +use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadSettingsSnapshot; @@ -11,11 +13,61 @@ pub(super) struct PersistedResumeSettings { pub(super) approval_policy: AskForApproval, pub(super) approvals_reviewer: Option, pub(super) active_permission_profile: Option, + pub(super) unnamed_permission_profile: Option, } -pub(super) fn latest_persisted_resume_settings( +impl PersistedResumeSettings { + pub(super) fn from_history(history: &[RolloutItem], thread_id: ThreadId) -> Option { + // A running turn can record stale permissions. Prefer a settings update whose + // owner is known, including a legacy update following that owner's checkpoint. + let mut latest_legacy = None; + for item in history.iter().rev() { + let RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) = item else { + continue; + }; + match event.thread_id { + Some(owner) if owner == thread_id => { + return Some(Self::from(latest_legacy.unwrap_or(&event.thread_settings))); + } + Some(_) => latest_legacy = None, + None => { + latest_legacy.get_or_insert(&event.thread_settings); + } + } + } + latest_persisted_resume_settings(history) + } +} + +impl From<&ThreadSettingsSnapshot> for PersistedResumeSettings { + fn from(settings: &ThreadSettingsSnapshot) -> Self { + Self { + approval_policy: settings.approval_policy, + approvals_reviewer: Some(settings.approvals_reviewer), + active_permission_profile: settings.active_permission_profile.clone(), + unnamed_permission_profile: settings + .active_permission_profile + .is_none() + .then(|| settings.permission_profile.clone()), + } + } +} + +pub(super) fn latest_owned_thread_settings( history: &[RolloutItem], -) -> Option { + thread_id: ThreadId, +) -> Option<&ThreadSettingsSnapshot> { + history.iter().rev().find_map(|item| match item { + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) + if event.thread_id == Some(thread_id) => + { + Some(&event.thread_settings) + } + _ => None, + }) +} + +fn latest_persisted_resume_settings(history: &[RolloutItem]) -> Option { history .iter() .enumerate() @@ -33,15 +85,17 @@ pub(super) fn latest_persisted_resume_settings( }) }), active_permission_profile: turn_context.active_permission_profile.clone(), + unnamed_permission_profile: None, }), RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => { + // History without an identifiable owner can supply a profile ID, but + // its raw filesystem grants may belong to another thread or executor. + let settings = &event.thread_settings; Some(PersistedResumeSettings { - approval_policy: event.thread_settings.approval_policy, - approvals_reviewer: Some(event.thread_settings.approvals_reviewer), - active_permission_profile: event - .thread_settings - .active_permission_profile - .clone(), + approval_policy: settings.approval_policy, + approvals_reviewer: Some(settings.approvals_reviewer), + active_permission_profile: settings.active_permission_profile.clone(), + unnamed_permission_profile: None, }) } _ => None, diff --git a/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs b/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs index 4531226b58a1..2a2ea5d3c6cf 100644 --- a/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs +++ b/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs @@ -1,5 +1,6 @@ use super::PersistedResumeSettings; use super::latest_persisted_resume_settings; +use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -22,13 +23,14 @@ fn cwd() -> AbsolutePathBuf { } fn settings_item( + thread_id: Option, approval_policy: AskForApproval, approvals_reviewer: ApprovalsReviewer, active_permission_profile: Option, ) -> RolloutItem { RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied( ThreadSettingsAppliedEvent { - thread_id: None, + thread_id, thread_settings: ThreadSettingsSnapshot { disabled_plugin_ids: Vec::new(), model: "gpt-5".to_string(), @@ -90,14 +92,17 @@ fn latest_settings_snapshot_wins() { approval_policy: AskForApproval::OnRequest, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), active_permission_profile: Some(ActivePermissionProfile::new("dev")), + unnamed_permission_profile: None, }; let history = vec![ settings_item( + /*thread_id*/ None, AskForApproval::Never, ApprovalsReviewer::User, /*active_permission_profile*/ None, ), settings_item( + /*thread_id*/ None, AskForApproval::OnRequest, ApprovalsReviewer::AutoReview, Some(ActivePermissionProfile::new("dev")), @@ -113,9 +118,11 @@ fn latest_turn_context_wins_over_earlier_settings_update() { approval_policy: AskForApproval::UnlessTrusted, approvals_reviewer: Some(ApprovalsReviewer::User), active_permission_profile: Some(ActivePermissionProfile::read_only()), + unnamed_permission_profile: None, }; let history = vec![ settings_item( + /*thread_id*/ None, AskForApproval::Never, ApprovalsReviewer::AutoReview, Some(ActivePermissionProfile::new("dev")), @@ -131,6 +138,69 @@ fn latest_turn_context_wins_over_earlier_settings_update() { assert_eq!(latest_persisted_resume_settings(&history), Some(expected)); } +#[test] +fn newer_legacy_permissions_only_supersede_their_own_threads_checkpoint() { + let thread_id = ThreadId::new(); + let other_thread_id = ThreadId::new(); + let workspace = ActivePermissionProfile::new(":workspace"); + let read_only = ActivePermissionProfile::read_only(); + let owned = settings_item( + Some(thread_id), + AskForApproval::Never, + ApprovalsReviewer::AutoReview, + Some(workspace.clone()), + ); + let legacy = settings_item( + /*thread_id*/ None, + AskForApproval::OnRequest, + ApprovalsReviewer::User, + Some(read_only.clone()), + ); + let stale_turn = turn_context_item( + "turn", + AskForApproval::Never, + Some(ApprovalsReviewer::AutoReview), + Some(workspace.clone()), + ); + let foreign = settings_item( + Some(other_thread_id), + AskForApproval::Never, + ApprovalsReviewer::AutoReview, + Some(workspace.clone()), + ); + let foreign_legacy = settings_item( + /*thread_id*/ None, + AskForApproval::Never, + ApprovalsReviewer::AutoReview, + Some(workspace.clone()), + ); + let mut history = vec![owned.clone(), legacy.clone(), stale_turn.clone()]; + let expected = Some(PersistedResumeSettings { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: Some(ApprovalsReviewer::User), + active_permission_profile: Some(read_only), + unnamed_permission_profile: None, + }); + assert_eq!( + PersistedResumeSettings::from_history(&history, thread_id), + expected + ); + history.extend([foreign.clone(), foreign_legacy]); + assert_eq!( + PersistedResumeSettings::from_history(&history, thread_id), + expected + ); + assert_eq!( + PersistedResumeSettings::from_history(&[owned, foreign, legacy, stale_turn], thread_id), + Some(PersistedResumeSettings { + approval_policy: AskForApproval::Never, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + active_permission_profile: Some(workspace), + unnamed_permission_profile: None, + }) + ); +} + #[test] fn older_reviewer_is_used_when_latest_turn_context_omits_it() { let history = vec![ @@ -154,6 +224,20 @@ fn older_reviewer_is_used_when_latest_turn_context_omits_it() { approval_policy: AskForApproval::OnRequest, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), active_permission_profile: None, + unnamed_permission_profile: None, }) ); } + +#[test] +fn an_unattributed_snapshot_does_not_restore_unnamed_permissions() { + let history = [settings_item( + /*thread_id*/ None, + AskForApproval::OnRequest, + ApprovalsReviewer::User, + /*active_permission_profile*/ None, + )]; + let settings = + PersistedResumeSettings::from_history(&history, ThreadId::new()).expect("legacy settings"); + assert_eq!(settings.unnamed_permission_profile, None); +} diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index b8907648bacc..f5f0665e4e67 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -5,8 +5,8 @@ mod daemon_continuation; mod daemon_snapshot; use super::persisted_resume_settings::PersistedResumeSettings; +use super::persisted_resume_settings::latest_owned_thread_settings; use super::persisted_resume_settings::latest_persisted_model_settings; -use super::persisted_resume_settings::latest_persisted_resume_settings; use super::thread_enrichment::enrich_loaded_threads; use super::thread_fork_goal::inherit_thread_goal_snapshot; use super::thread_input::can_accept_direct_input; @@ -38,7 +38,6 @@ use codex_protocol::SanitizedGitUrl; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErrorDetails; use codex_protocol::mcp::ClientMcpExtensions; -use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadHistoryMode; use codex_thread_store::PersistContext; @@ -105,7 +104,6 @@ struct ResumeConfigState { workspace_roots: Option>, persisted_metadata: Option, persisted_settings: Option, - persisted_permission_profile: Option, } struct PreparedResumeConfig { @@ -309,6 +307,29 @@ fn has_permission_override( }) } +fn apply_persisted_permission_profile( + settings: &PersistedResumeSettings, + request_overrides: Option<&HashMap>, + config_overrides: &mut ConfigOverrides, +) { + if has_permission_override(request_overrides, config_overrides) { + return; + } + config_overrides.persisted_permission_profile_id = settings + .active_permission_profile + .as_ref() + .map(|profile| profile.id.clone()); + if !request_overrides.is_some_and(|overrides| { + overrides.keys().any(|key| { + key == "sandbox_workspace_write" || key.starts_with("sandbox_workspace_write.") + }) + }) { + config_overrides + .persisted_permission_profile + .clone_from(&settings.unnamed_permission_profile); + } +} + fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> { const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128; const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64; @@ -4139,16 +4160,9 @@ impl ThreadRequestProcessor { } // Copied or referenced history can contain another thread's settings. Only snapshots - // explicitly owned by this thread can restore its permissions, cwd, and workspace folders. + // explicitly owned by this thread can restore its cwd and workspace folders. let history_settings = if let InitialHistory::Resumed(resumed) = &thread_history { - resumed.history.iter().rev().find_map(|item| match item { - RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) - if event.thread_id == Some(resumed.conversation_id) => - { - Some(&event.thread_settings) - } - _ => None, - }) + latest_owned_thread_settings(&resumed.history, resumed.conversation_id) } else { None }; @@ -4242,27 +4256,12 @@ impl ThreadRequestProcessor { let restore_summary = !has_override("model_reasoning_summary"); let restore_personality = typesafe_overrides.personality.is_none() && !has_override("personality"); - typesafe_overrides.persisted_permission_profile = history_settings - .filter(|settings| { - settings.active_permission_profile.is_none() - && !has_permission_override(request_overrides.as_ref(), &typesafe_overrides) - && !request_overrides.as_ref().is_some_and(|overrides| { - overrides.keys().any(|key| { - key == "sandbox_workspace_write" - || key.starts_with("sandbox_workspace_write.") - }) - }) - }) - .map(|settings| settings.permission_profile.clone()); - let mut persisted_settings = match &thread_history { - InitialHistory::Resumed(resumed) => latest_persisted_resume_settings(&resumed.history), + let persisted_settings = match &thread_history { + InitialHistory::Resumed(resumed) => { + PersistedResumeSettings::from_history(&resumed.history, resumed.conversation_id) + } InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, }; - // A TurnContext can describe an executor's profile, so an owned snapshot - // is authoritative for named permissions too. - if let (Some(settings), Some(snapshot)) = (&mut persisted_settings, history_settings) { - settings.active_permission_profile = snapshot.active_permission_profile.clone(); - } let persisted_metadata = self .load_and_apply_persisted_resume_metadata( &thread_history, @@ -4281,7 +4280,6 @@ impl ThreadRequestProcessor { workspace_roots: typesafe_overrides.workspace_roots.clone(), persisted_metadata, persisted_settings, - persisted_permission_profile: typesafe_overrides.persisted_permission_profile.clone(), }; let mut config = match prepared_config.take() { Some(prepared) if prepared.state == config_state => prepared.config, @@ -4604,12 +4602,11 @@ impl ThreadRequestProcessor { { typesafe_overrides.approvals_reviewer = persisted_settings.approvals_reviewer; } - if !has_permission_override(request_overrides.as_ref(), typesafe_overrides) { - typesafe_overrides.persisted_permission_profile_id = persisted_settings - .active_permission_profile - .as_ref() - .map(|profile| profile.id.clone()); - } + apply_persisted_permission_profile( + persisted_settings, + request_overrides.as_ref(), + typesafe_overrides, + ); } let state_db_ctx = self.state_db.clone()?; let persisted_metadata = state_db_ctx @@ -5526,11 +5523,7 @@ impl ThreadRequestProcessor { let loaded_parent_settings = if paginated_source && needs_latest_settings { if let Some(parent) = loaded_parent.as_ref() { let snapshot = parent.thread_settings_snapshot().await; - Some(PersistedResumeSettings { - approval_policy: snapshot.approval_policy, - approvals_reviewer: Some(snapshot.approvals_reviewer), - active_permission_profile: snapshot.active_permission_profile, - }) + Some(PersistedResumeSettings::from(&snapshot)) } else { None } @@ -5570,11 +5563,10 @@ impl ThreadRequestProcessor { .and_then(|parent| parent.multi_agent_version()) }); let persisted_settings = loaded_parent_settings.or_else(|| { - latest_persisted_resume_settings( - latest_context - .as_deref() - .unwrap_or_else(|| source_history_items.as_ref()), - ) + let history = latest_context + .as_deref() + .unwrap_or_else(|| source_history_items.as_ref()); + PersistedResumeSettings::from_history(history, source_thread_id) }); if let Some(persisted_settings) = persisted_settings { if restore_approval_policy { @@ -5583,11 +5575,11 @@ impl ThreadRequestProcessor { if restore_approvals_reviewer { typesafe_overrides.approvals_reviewer = persisted_settings.approvals_reviewer; } - if restore_permission_profile { - typesafe_overrides.persisted_permission_profile_id = persisted_settings - .active_permission_profile - .map(|profile| profile.id); - } + apply_persisted_permission_profile( + &persisted_settings, + request_overrides.as_ref(), + &mut typesafe_overrides, + ); } let (inherited_options, inherited_settings) = inherited_fork.unzip(); let options = if let Some(options) = inherited_options { diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 11678f0961da..146cadfda385 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_rollout::RolloutItem; use codex_rollout::RolloutLine; +use codex_rollout::RolloutRecorder; use codex_rollout::append_rollout_item_to_path; use codex_rollout::append_thread_name; use codex_rollout::read_session_meta_line; @@ -442,7 +443,7 @@ async fn assert_thread_fork_preserves_persisted_approvals_reviewer( let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; - let (source_thread_id, source_turn_id) = { + let (source_thread_id, source_turn_id, rollout_path) = { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() @@ -530,9 +531,20 @@ async fn assert_thread_fork_preserves_persisted_approvals_reviewer( ); } - (thread.id, turn.id) + ( + thread.id, + turn.id, + thread.path.expect("source rollout path"), + ) }; + // The first turn records its workspace permissions after the source switched to read-only. + let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?; + let frozen_context = items.into_iter().find(|item| { + matches!(item, RolloutItem::TurnContext(context) if context.turn_id.as_deref() == Some(&source_turn_id)) + }).expect("first turn context"); + append_rollout_item_to_path(&rollout_path, &frozen_context).await?; + let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 917952842892..060522cdb720 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1018,6 +1018,82 @@ sandbox = "unelevated" Ok(()) } +#[tokio::test] +async fn resume_and_fork_restore_unnamed_permissions_from_a_legacy_update() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()) + .with_sandbox_mode("workspace-write") + .write(codex_home.path())?; + let stamp = "2025-01-05T12-00-00"; + let thread_id = create_fake_rollout( + codex_home.path(), + stamp, + "2025-01-05T12:00:00Z", + "Unnamed permissions", + Some("mock_provider"), + /*git_info*/ None, + )?; + let path = rollout_path(codex_home.path(), stamp, &thread_id); + let owned: ThreadSettingsAppliedEvent = serde_json::from_value(json!({ + "thread_id": thread_id, + "thread_settings": { + "model": "gpt-5.5", + "model_provider_id": "mock_provider", + "cwd": normalized_existing_path(codex_home.path())?, + "approval_policy": "never", + "approvals_reviewer": "user", + "permission_profile": PermissionProfile::workspace_write(), + "collaboration_mode": {"mode": "default", "settings": {"model": "gpt-5.5"}}, + }, + }))?; + let mut legacy = owned.clone(); + legacy.thread_id = None; + legacy.thread_settings.permission_profile = PermissionProfile::read_only(); + for event in [owned, legacy] { + append_rollout_item_to_path( + &path, + &RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)), + ) + .await?; + } + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_managed_config() + .build_initialized() + .await?; + // Fork first so it reads the original history before resume writes a checkpoint. + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread_id.clone(), + ..Default::default() + }) + .await?; + let fork: ThreadForkResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??; + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id, + ..Default::default() + }) + .await?; + let resumed: ThreadResumeResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; + let expected = ( + AppSandboxPolicy::ReadOnly { + network_access: false, + }, + None, + ); + assert_eq!( + (resumed.sandbox, resumed.active_permission_profile), + expected + ); + assert_eq!((fork.sandbox, fork.active_permission_profile), expected); + Ok(()) +} + #[tokio::test] async fn cold_resume_workspace_roots_validate_foreign_startup_paths() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1474,6 +1550,9 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), history_mode: Some(ThreadHistoryMode::Legacy), + approval_policy: Some(AskForApproval::Never), + approvals_reviewer: Some(ApprovalsReviewer::User), + permissions: Some(":workspace".to_string()), ..Default::default() }) .await?; @@ -1516,7 +1595,9 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe thread_id: thread.id.clone(), model: Some("gpt-5.2-codex".to_string()), effort: Some(ReasoningEffort::Ultra), + approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + permissions: Some(":read-only".to_string()), cwd: Some(persisted_cwd.clone()), ..Default::default() }) @@ -1607,7 +1688,7 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe .await??; timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??; - // Older rollouts can retain a frozen turn context after an accepted settings update. + // A running turn can record its original permissions after the thread accepts an update. let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?; let frozen_context = items .into_iter() @@ -1619,6 +1700,21 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe .with_codex_home(codex_home.path()) .build_initialized() .await?; + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread_id.clone(), + ..Default::default() + }) + .await?; + let fork: ThreadForkResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??; + assert_eq!(fork.approval_policy, AskForApproval::OnRequest); + assert_eq!(fork.approvals_reviewer, ApprovalsReviewer::AutoReview); + assert_eq!( + fork.active_permission_profile, + Some(ActivePermissionProfile::new(":read-only")) + ); + let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.clone(), @@ -1630,10 +1726,25 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe cwd, reasoning_effort, collaboration_mode, + approval_policy, + approvals_reviewer, + active_permission_profile, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); assert_eq!(cwd.as_path(), persisted_cwd); + assert_eq!( + ( + approval_policy, + approvals_reviewer, + active_permission_profile + ), + ( + AskForApproval::OnRequest, + ApprovalsReviewer::AutoReview, + Some(ActivePermissionProfile::new(":read-only")), + ) + ); let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?; let mode = items.iter().rev().find_map(|item| match item { RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) From 7948c0753170216308ae346d27d9cee25c561c2b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 25 Oct 2026 22:12:21 +0000 Subject: [PATCH 050/287] Give remote control pairing a 35-second deadline (#57228) ## Why Pairing can take up to 30 seconds in the daemon, but the client used a two-second timeout when waiting for each response message. ## What changed Apply a single 35-second deadline to `start_pairing`, including connection and initialization. Wait for the pairing response without a per-message timeout so status notifications cannot extend the overall deadline. Preserve the existing per-message timeout for other remote control requests. ## Testing Extend the manual pairing test with a three-second response delay and add coverage verifying that a status notification does not reset the pairing deadline. GitOrigin-RevId: c011fd131262b30baa21e17bc0e0f733dce53fb0 --- codex-rs/app-server-daemon/Cargo.toml | 1 + .../src/remote_control_client.rs | 83 +++++++++++++++++-- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server-daemon/Cargo.toml b/codex-rs/app-server-daemon/Cargo.toml index 788f92f69c17..4bd1678402c2 100644 --- a/codex-rs/app-server-daemon/Cargo.toml +++ b/codex-rs/app-server-daemon/Cargo.toml @@ -45,6 +45,7 @@ tracing-appender = { workspace = true } [dev-dependencies] codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true } [target.'cfg(windows)'.dependencies] diff --git a/codex-rs/app-server-daemon/src/remote_control_client.rs b/codex-rs/app-server-daemon/src/remote_control_client.rs index 74c978f21930..d48a71f851e3 100644 --- a/codex-rs/app-server-daemon/src/remote_control_client.rs +++ b/codex-rs/app-server-daemon/src/remote_control_client.rs @@ -28,6 +28,8 @@ use crate::RemoteControlReadyStatus; use crate::client; const REMOTE_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(10); +// Allow the daemon's 30-second pairing request to finish and return its response. +const PAIRING_TIMEOUT: Duration = Duration::from_secs(35); const REMOTE_CONTROL_REQUEST_ID: RequestId = RequestId::Integer(2); const INVALID_PARAMS_ERROR_CODE: i64 = -32602; @@ -56,6 +58,12 @@ pub(crate) async fn disable_remote_control(socket_path: &Path) -> Result Result { + timeout(PAIRING_TIMEOUT, start_pairing_inner(socket_path)) + .await + .context("timed out waiting for remoteControl/pairing/start response")? +} + +async fn start_pairing_inner(socket_path: &Path) -> Result { let mut websocket = client::connect(socket_path).await?; initialize_client(&mut websocket).await?; let params = serde_json::to_value(RemoteControlPairingStartParams { manual_code: true })?; @@ -70,6 +78,7 @@ pub(crate) async fn start_pairing(socket_path: &Path) -> Result Ok(response), RemoteControlRpcResponse::InvalidParams => { send_remote_control_request( @@ -177,8 +194,13 @@ where /*params*/ None, ) .await?; - match read_remote_control_response(websocket, &REMOTE_CONTROL_REQUEST_ID, method) - .await? + match read_remote_control_response( + websocket, + &REMOTE_CONTROL_REQUEST_ID, + method, + message_timeout, + ) + .await? { RemoteControlRpcResponse::Success(response) => Ok(response), RemoteControlRpcResponse::InvalidParams => { @@ -217,18 +239,21 @@ async fn read_remote_control_response( websocket: &mut WebSocketStream, request_id: &RequestId, method: &str, + message_timeout: Option, ) -> Result> where S: AsyncRead + AsyncWrite + Unpin, T: DeserializeOwned, { loop { - let message = timeout( - client::CONTROL_SOCKET_RESPONSE_TIMEOUT, - client::read_message(websocket), - ) - .await - .with_context(|| format!("timed out waiting for {method} response"))??; + let read = client::read_message(websocket); + let message = if let Some(duration) = message_timeout { + timeout(duration, read) + .await + .with_context(|| format!("timed out waiting for {method} response"))?? + } else { + read.await? + }; match message { JSONRPCMessage::Response(response) if response.id == *request_id => { let response = serde_json::from_value::(response.result) @@ -586,6 +611,10 @@ mod tests { pairing.params, Some(serde_json::json!({ "manualCode": true })) ); + // Use virtual time only for the reply delay, after the socket handshake. + tokio::time::pause(); + sleep(Duration::from_secs(3)).await; + tokio::time::resume(); client::send_message( &mut websocket, &JSONRPCMessage::Response(JSONRPCResponse { @@ -616,6 +645,42 @@ mod tests { Ok(()) } + #[tokio::test] + async fn pairing_notification_does_not_extend_the_response_deadline() -> Result<()> { + let dir = TempDir::new()?; + let socket_path = dir.path().join("app-server.sock"); + let listener = UnixListener::bind(&socket_path).await?; + let server_task = tokio::spawn(async move { + let mut websocket = accept_initialized_client(listener).await?; + client::read_message(&mut websocket).await?; + + tokio::time::pause(); + sleep(Duration::from_secs(20)).await; + send_remote_control_status( + &mut websocket, + remote_control_status( + RemoteControlConnectionStatus::Connecting, + /*environment_id*/ None, + ), + ) + .await?; + // With a fresh timeout after the notification, the client would see EOF at 40s. + sleep(Duration::from_secs(20)).await; + Ok::<_, anyhow::Error>(()) + }); + + let start = Instant::now(); + let error = start_pairing(&socket_path).await.unwrap_err(); + let elapsed = start.elapsed(); + server_task.await??; + assert!(error.is::(), "{error:#}"); + assert!( + (Duration::from_secs(30)..Duration::from_secs(40)).contains(&elapsed), + "pairing timed out after {elapsed:?}" + ); + Ok(()) + } + struct EnableScenario { initial_notification: Option, enable_response: RemoteControlStatusChangedNotification, From 0b021a5fc981448a2555bd378518e85e3aae3016 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 26 Oct 2026 00:23:05 +0000 Subject: [PATCH 051/287] Simplify code-mode cell completion delivery (#57229) ## What changed Remove unused pending initial-yield state and its plumbing from cell completion handling. Route completion delivery through `send_cell_event`, retaining the completion event and original timestamp when an observer has disconnected. ## Testing Extend `failed_completion_delivery_rebuffers_the_event` to verify that failed delivery attempts preserve output and report the full elapsed buffering duration on a later successful observation. GitOrigin-RevId: eebe990a4f8258f1ccb12d0694945678f0cc3a8f --- .../src/cell_actor/callbacks_tests.rs | 1 - .../code-mode-runtime/src/cell_actor/mod.rs | 4 +- .../code-mode-runtime/src/cell_actor/tests.rs | 183 ++---------------- .../code-mode-runtime/src/cell_actor/types.rs | 142 ++++---------- .../src/session_runtime/mod.rs | 3 +- .../src/session_runtime/tests.rs | 1 - 6 files changed, 51 insertions(+), 283 deletions(-) diff --git a/codex-rs/code-mode-runtime/src/cell_actor/callbacks_tests.rs b/codex-rs/code-mode-runtime/src/cell_actor/callbacks_tests.rs index 23c4bc96340d..6d2444b8bec3 100644 --- a/codex-rs/code-mode-runtime/src/cell_actor/callbacks_tests.rs +++ b/codex-rs/code-mode-runtime/src/cell_actor/callbacks_tests.rs @@ -41,7 +41,6 @@ impl CellHost for PanickingCallbackHost { &self, _stored_value_writes: HashMap>, _event: CellEvent, - _pending_initial_yield_items: Option>, _cell_state: Arc, ) -> CompletionCommit { panic!("unexpected completion commit"); diff --git a/codex-rs/code-mode-runtime/src/cell_actor/mod.rs b/codex-rs/code-mode-runtime/src/cell_actor/mod.rs index 1ccdb01c143f..c44228aad7d5 100644 --- a/codex-rs/code-mode-runtime/src/cell_actor/mod.rs +++ b/codex-rs/code-mode-runtime/src/cell_actor/mod.rs @@ -184,7 +184,7 @@ async fn run_cell( if response_tx.is_closed() { continue; } - let response_tx = match cell_state.route_observation(mode, response_tx) { + let response_tx = match cell_state.route_observation(response_tx) { ObservationDelivery::Running(response_tx) => response_tx, ObservationDelivery::Delivered => break, ObservationDelivery::Buffered | ObservationDelivery::Closed => continue, @@ -322,7 +322,6 @@ async fn run_cell( .commit_completion( HashMap::new(), event, - /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ) .await @@ -479,7 +478,6 @@ async fn run_cell( .commit_completion( stored_value_writes, event, - /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ) .await diff --git a/codex-rs/code-mode-runtime/src/cell_actor/tests.rs b/codex-rs/code-mode-runtime/src/cell_actor/tests.rs index e85ea96eb330..fb25a7491a4b 100644 --- a/codex-rs/code-mode-runtime/src/cell_actor/tests.rs +++ b/codex-rs/code-mode-runtime/src/cell_actor/tests.rs @@ -45,10 +45,9 @@ impl CellHost for TestHost { &self, _stored_value_writes: HashMap>, event: CellEvent, - pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { - cell_state.commit_completion(event, pending_initial_yield_items, || {}) + cell_state.commit_completion(event, || {}) } async fn closed(&self) {} @@ -77,10 +76,9 @@ impl CellHost for RecordingHost { &self, _stored_value_writes: HashMap>, event: CellEvent, - pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { - cell_state.commit_completion(event, pending_initial_yield_items, || {}) + cell_state.commit_completion(event, || {}) } async fn closed(&self) {} @@ -479,11 +477,7 @@ async fn only_the_first_termination_claims_a_buffered_completion() { error_text: None, }; assert_eq!( - cell_state.commit_completion( - completion.clone(), - /*pending_initial_yield_items*/ None, - || {} - ), + cell_state.commit_completion(completion.clone(), || {}), CompletionCommit::Committed ); assert!(matches!( @@ -517,11 +511,7 @@ async fn termination_claim_prevents_stored_value_commit() { }; assert_eq!( - cell_state.commit_completion( - completion.clone(), - /*pending_initial_yield_items*/ None, - || commit_ran = true - ), + cell_state.commit_completion(completion.clone(), || commit_ran = true), CompletionCommit::Rejected(completion) ); assert!(!commit_ran); @@ -539,17 +529,15 @@ async fn termination_claim_prevents_stored_value_commit() { #[tokio::test(start_paused = true)] async fn failed_completion_delivery_rebuffers_the_event() { let cell_state = CellState::new(CancellationToken::new()); - let event = CellEvent::Completed { - content_items: Vec::new(), - result_buffered_for: Duration::ZERO, + let completion = |result_buffered_for| CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "done".to_string(), + }], + result_buffered_for, error_text: None, }; assert_eq!( - cell_state.commit_completion( - event.clone(), - /*pending_initial_yield_items*/ None, - || {} - ), + cell_state.commit_completion(completion(Duration::ZERO), || {}), CompletionCommit::Committed ); let (response_tx, response_rx) = oneshot::channel(); @@ -560,159 +548,20 @@ async fn failed_completion_delivery_rebuffers_the_event() { )); assert!(cell_state.accepting_observations()); - let (response_tx, mut response_rx) = oneshot::channel(); - assert!(matches!( - cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), - ObservationDelivery::Delivered - )); - assert_eq!(response_rx.try_recv(), Ok(Ok(event))); -} - -#[tokio::test(start_paused = true)] -async fn buffered_initial_yield_precedes_buffered_completion_for_yield_observer() { - let cell_state = CellState::new(CancellationToken::new()); - let completion = CellEvent::Completed { - content_items: vec![OutputItem::Text { - text: "after".to_string(), - }], - result_buffered_for: Duration::ZERO, - error_text: None, - }; - assert_eq!( - cell_state.commit_completion( - completion.clone(), - Some(vec![OutputItem::Text { - text: "before".to_string(), - }]), - || {} - ), - CompletionCommit::Committed - ); - assert!(matches!( - cell_state.deliver_completion(/*response_tx*/ None), - CompletionDelivery::Buffered - )); - - let (response_tx, mut response_rx) = oneshot::channel(); - assert!(matches!( - cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), - ObservationDelivery::Buffered - )); - assert_eq!( - response_rx.try_recv(), - Ok(Ok(CellEvent::Yielded { - content_items: vec![OutputItem::Text { - text: "before".to_string(), - }], - })) - ); - - let (response_tx, mut response_rx) = oneshot::channel(); - assert!(matches!( - cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), - ObservationDelivery::Delivered - )); - assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); -} - -#[tokio::test(start_paused = true)] -async fn pending_observer_merges_initial_yield_and_completion_output() { - let cell_state = CellState::new(CancellationToken::new()); - assert_eq!( - cell_state.commit_completion( - CellEvent::Completed { - content_items: vec![OutputItem::Text { - text: "after".to_string(), - }], - result_buffered_for: Duration::ZERO, - error_text: None, - }, - Some(vec![OutputItem::Text { - text: "before".to_string(), - }]), - || {} - ), - CompletionCommit::Committed - ); - assert!(matches!( - cell_state.deliver_completion(/*response_tx*/ None), - CompletionDelivery::Buffered - )); - - let buffered = Duration::from_secs(30); - tokio::time::advance(buffered).await; - let (response_tx, mut response_rx) = oneshot::channel(); - assert!(matches!( - cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), - ObservationDelivery::Delivered - )); - assert_eq!( - response_rx.try_recv(), - Ok(Ok(CellEvent::Completed { - content_items: vec![ - OutputItem::Text { - text: "before".to_string(), - }, - OutputItem::Text { - text: "after".to_string(), - }, - ], - result_buffered_for: buffered, - error_text: None, - })) - ); -} - -#[tokio::test(start_paused = true)] -async fn dropped_pending_observation_preserves_the_initial_yield_boundary() { - let cell_state = CellState::new(CancellationToken::new()); - let completion = CellEvent::Completed { - content_items: vec![OutputItem::Text { - text: "after".to_string(), - }], - result_buffered_for: Duration::ZERO, - error_text: None, - }; - assert_eq!( - cell_state.commit_completion( - completion.clone(), - Some(vec![OutputItem::Text { - text: "before".to_string(), - }]), - || {} - ), - CompletionCommit::Committed - ); - assert!(matches!( - cell_state.deliver_completion(/*response_tx*/ None), - CompletionDelivery::Buffered - )); - + let interval = Duration::from_secs(5); + tokio::time::advance(interval).await; let (response_tx, response_rx) = oneshot::channel(); drop(response_rx); assert!(matches!( - cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), - ObservationDelivery::Buffered - )); - - let (response_tx, mut response_rx) = oneshot::channel(); - assert!(matches!( - cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + cell_state.route_observation(response_tx), ObservationDelivery::Buffered )); - assert_eq!( - response_rx.try_recv(), - Ok(Ok(CellEvent::Yielded { - content_items: vec![OutputItem::Text { - text: "before".to_string(), - }], - })) - ); + tokio::time::advance(interval).await; let (response_tx, mut response_rx) = oneshot::channel(); assert!(matches!( - cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + cell_state.route_observation(response_tx), ObservationDelivery::Delivered )); - assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); + assert_eq!(response_rx.try_recv(), Ok(Ok(completion(2 * interval)))); } diff --git a/codex-rs/code-mode-runtime/src/cell_actor/types.rs b/codex-rs/code-mode-runtime/src/cell_actor/types.rs index 3f6be17aac64..179022c3f0cc 100644 --- a/codex-rs/code-mode-runtime/src/cell_actor/types.rs +++ b/codex-rs/code-mode-runtime/src/cell_actor/types.rs @@ -10,10 +10,9 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; +use super::send_cell_event; use crate::session_runtime::CellEvent; use crate::session_runtime::Observation; -use crate::session_runtime::ObserveMode; -use crate::session_runtime::OutputItem; use crate::session_runtime::ToolKind; use crate::session_runtime::ToolName; @@ -57,7 +56,6 @@ pub(crate) trait CellHost: Send + Sync + 'static { &self, stored_value_writes: HashMap>, event: CellEvent, - pending_initial_yield_items: Option>, cell_state: Arc, ) -> impl Future + Send; @@ -121,8 +119,6 @@ enum CellPhase { response_tx: oneshot::Sender>, }, Completed { - // Set only when `yield_control()` races the create-to-first-observe handoff. - pending_initial_yield_items: Option>, event: CellEvent, completed_at: Instant, }, @@ -186,11 +182,10 @@ impl CellState { Box::pin(async { Err(CellError::AlreadyTerminating) }) } CellPhase::Completed { - pending_initial_yield_items, event, completed_at, } => { - let event = buffered_completion(event, pending_initial_yield_items, completed_at); + let event = buffered_completion(event, completed_at); *phase = CellPhase::CompletionClaimed(event.clone()); self.cancellation_token.cancel(); ready_event(event) @@ -206,7 +201,6 @@ impl CellState { pub(crate) fn commit_completion( &self, event: CellEvent, - pending_initial_yield_items: Option>, commit: impl FnOnce(), ) -> CompletionCommit { let mut phase = self @@ -218,7 +212,6 @@ impl CellState { } commit(); *phase = CellPhase::Completed { - pending_initial_yield_items, event, completed_at: Instant::now(), }; @@ -233,48 +226,40 @@ impl CellState { .phase .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let (pending_initial_yield_items, event, completed_at) = - match std::mem::replace(&mut *phase, CellPhase::Tombstone) { - CellPhase::Completed { - pending_initial_yield_items, - event, - completed_at, - } => (pending_initial_yield_items, event, completed_at), - previous => { - *phase = previous; - return CompletionDelivery::Rejected(response_tx); - } - }; + let (event, completed_at) = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Completed { + event, + completed_at, + } => (event, completed_at), + previous => { + *phase = previous; + return CompletionDelivery::Rejected(response_tx); + } + }; let Some(response_tx) = response_tx else { *phase = CellPhase::Completed { - pending_initial_yield_items, event, completed_at, }; return CompletionDelivery::Buffered; }; - match response_tx.send(Ok(event)) { + match send_cell_event(response_tx, event) { Ok(()) => { self.cancellation_token.cancel(); CompletionDelivery::Delivered } - Err(Ok(event)) => { + Err(event) => { *phase = CellPhase::Completed { - pending_initial_yield_items, event, completed_at, }; CompletionDelivery::Buffered } - Err(Err(error)) => { - panic!("completion delivery unexpectedly carried an actor error: {error:?}") - } } } pub(crate) fn route_observation( &self, - mode: ObserveMode, response_tx: oneshot::Sender>, ) -> ObservationDelivery { let mut phase = self @@ -287,63 +272,21 @@ impl CellState { ObservationDelivery::Running(response_tx) } CellPhase::Completed { - pending_initial_yield_items: Some(content_items), event, completed_at, - } if matches!(mode, ObserveMode::YieldAfter(_)) => { - match response_tx.send(Ok(CellEvent::Yielded { content_items })) { - Ok(()) => { - *phase = CellPhase::Completed { - pending_initial_yield_items: None, - event, - completed_at, - }; - ObservationDelivery::Buffered - } - Err(Ok(CellEvent::Yielded { content_items })) => { - *phase = CellPhase::Completed { - pending_initial_yield_items: Some(content_items), - event, - completed_at, - }; - ObservationDelivery::Buffered - } - Err(Ok(event)) => { - panic!("initial yield delivery returned an unexpected event: {event:?}") - } - Err(Err(error)) => { - panic!("initial yield delivery returned an actor error: {error:?}") - } + } => match send_cell_event(response_tx, buffered_completion(event, completed_at)) { + Ok(()) => { + self.cancellation_token.cancel(); + ObservationDelivery::Delivered } - } - CellPhase::Completed { - pending_initial_yield_items, - event, - completed_at, - } => { - let delivered_event = buffered_completion( - event.clone(), - pending_initial_yield_items.clone(), - completed_at, - ); - match response_tx.send(Ok(delivered_event)) { - Ok(()) => { - self.cancellation_token.cancel(); - ObservationDelivery::Delivered - } - Err(Ok(_)) => { - *phase = CellPhase::Completed { - pending_initial_yield_items, - event, - completed_at, - }; - ObservationDelivery::Buffered - } - Err(Err(error)) => { - panic!("completion delivery unexpectedly carried an actor error: {error:?}") - } + Err(event) => { + *phase = CellPhase::Completed { + event, + completed_at, + }; + ObservationDelivery::Buffered } - } + }, CellPhase::Terminating { response_tx: termination_tx, } => { @@ -377,14 +320,9 @@ impl CellState { Some(event) } CellPhase::Completed { - pending_initial_yield_items, event, completed_at, - } => Some(buffered_completion( - event, - pending_initial_yield_items, - completed_at, - )), + } => Some(buffered_completion(event, completed_at)), CellPhase::CompletionClaimed(completed_event) => Some(completed_event), CellPhase::Tombstone => None, }; @@ -405,27 +343,13 @@ impl CellState { } } -fn buffered_completion( - mut event: CellEvent, - pending_initial_yield_items: Option>, - completed_at: Instant, -) -> CellEvent { - let content_items = match &mut event { - CellEvent::Completed { - content_items, - result_buffered_for, - .. - } => { - *result_buffered_for = completed_at.elapsed(); - content_items - } - CellEvent::Yielded { content_items } - | CellEvent::Pending { content_items, .. } - | CellEvent::Terminated { content_items } => content_items, - }; - if let Some(mut initial) = pending_initial_yield_items { - initial.append(content_items); - *content_items = initial; +fn buffered_completion(mut event: CellEvent, completed_at: Instant) -> CellEvent { + if let CellEvent::Completed { + result_buffered_for, + .. + } = &mut event + { + *result_buffered_for = completed_at.elapsed(); } event } diff --git a/codex-rs/code-mode-runtime/src/session_runtime/mod.rs b/codex-rs/code-mode-runtime/src/session_runtime/mod.rs index 9b27e72f3de4..c500b816bdca 100644 --- a/codex-rs/code-mode-runtime/src/session_runtime/mod.rs +++ b/codex-rs/code-mode-runtime/src/session_runtime/mod.rs @@ -287,7 +287,6 @@ impl CellHost for RuntimeCellHost { &self, stored_value_writes: HashMap>, event: CellEvent, - pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { let cancellation_token = cell_state.cancellation_token(); @@ -298,7 +297,7 @@ impl CellHost for RuntimeCellHost { } stored_values = self.inner.stored_values.lock() => stored_values, }; - cell_state.commit_completion(event, pending_initial_yield_items, || { + cell_state.commit_completion(event, || { stored_values.extend(stored_value_writes); }) } diff --git a/codex-rs/code-mode-runtime/src/session_runtime/tests.rs b/codex-rs/code-mode-runtime/src/session_runtime/tests.rs index 9d8a3f28a111..af413b19167c 100644 --- a/codex-rs/code-mode-runtime/src/session_runtime/tests.rs +++ b/codex-rs/code-mode-runtime/src/session_runtime/tests.rs @@ -119,7 +119,6 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa Arc::new(JsonValue::String("lost".to_string())), )]), completion.clone(), - /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ); tokio::pin!(commit); From 4fc522f5e0d3187af8047c4d666d67da3a27ef9b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 26 Oct 2026 07:07:34 +0000 Subject: [PATCH 052/287] Add optional thread resumption to `codex queue` (#57232) ## What changed Add `codex queue --resume` to resume an unloaded thread after queueing a message through the local daemon or selected `--remote` server. Already loaded threads are left running. If resumption fails, emit a warning while preserving the successful queue result. Read only the thread status when checking whether to resume, allowing remote metadata to contain paths from another operating system. ## Testing Extend CLI integration coverage for unloaded threads consuming queued messages, archived thread rejection, and successful queueing when another server owns the thread and rejects resumption. GitOrigin-RevId: cc8aa3c11a37e990da36e6ff9a17934f73322e7b --- codex-rs/cli/src/queue_cmd.rs | 7 ++ codex-rs/cli/tests/queue.rs | 118 ++++++++++++++++++--- codex-rs/tui/src/session_queue_commands.rs | 57 ++++++++++ 3 files changed, 168 insertions(+), 14 deletions(-) diff --git a/codex-rs/cli/src/queue_cmd.rs b/codex-rs/cli/src/queue_cmd.rs index c8bf9f707dff..8a1b997f39de 100644 --- a/codex-rs/cli/src/queue_cmd.rs +++ b/codex-rs/cli/src/queue_cmd.rs @@ -19,6 +19,11 @@ pub(crate) struct QueueCommand { #[arg(long, value_name = "TEXT", value_parser = NonEmptyStringValueParser::new())] message: String, + /// After queueing, try to resume the thread on the local daemon or selected --remote server. + /// A failed resume produces a warning and the command still succeeds. + #[arg(long)] + resume: bool, + #[clap(flatten)] remote: InteractiveRemoteOptions, @@ -37,6 +42,7 @@ pub(crate) async fn run_queue_command( let QueueCommand { thread, message, + resume, remote, config_overrides, } = command; @@ -52,6 +58,7 @@ pub(crate) async fn run_queue_command( codex_tui::run_session_queue_command( thread, message, + resume, codex_tui::SessionArchiveCommandOptions { cli, arg0_paths, diff --git a/codex-rs/cli/tests/queue.rs b/codex-rs/cli/tests/queue.rs index 25aafe2e8250..7d15124cb1d9 100644 --- a/codex-rs/cli/tests/queue.rs +++ b/codex-rs/cli/tests/queue.rs @@ -17,6 +17,7 @@ const THREAD_ID: &str = "123e4567-e89b-12d3-a456-426614174000"; enum QueueResponse { Success, + ResumeRejected, MethodNotFound, #[cfg(unix)] UnknownMethodVariant, @@ -72,9 +73,10 @@ where let request = websocket.next().await.context("missing queue request")??; let request: Value = serde_json::from_str(request.to_text()?)?; assert_eq!(request["method"], "thread/queue/add"); + assert_eq!(request["params"]["threadId"], THREAD_ID); let result = match response { - QueueResponse::Success => json!({ + QueueResponse::Success | QueueResponse::ResumeRejected => json!({ "id": request["id"], "result": { "queuedSubmission": { @@ -100,6 +102,27 @@ where websocket .send(Message::Text(result.to_string().into())) .await?; + if matches!(response, QueueResponse::ResumeRejected) { + for (method, mut reply) in [ + ( + "thread/read", + json!({"result": {"thread": {"status": {"type": "notLoaded"}, "cwd": r"C:\workspace"}}}), + ), + ( + "thread/resume", + json!({"error": {"code": -32603, "message": "thread already has an active writer"}}), + ), + ] { + let rpc = websocket.next().await.context("missing resume request")??; + let rpc: Value = serde_json::from_str(rpc.to_text()?)?; + assert_eq!(rpc["method"], method); + assert_eq!(rpc["params"]["threadId"], THREAD_ID); + reply["id"] = rpc["id"].clone(); + websocket + .send(Message::Text(reply.to_string().into())) + .await?; + } + } Ok(request) } @@ -121,6 +144,22 @@ async fn queue_submits_message_to_remote_app_server() -> Result<()> { Ok(()) } +#[tokio::test] +async fn queue_succeeds_when_another_server_owns_the_thread() -> Result<()> { + let (output, _) = run_remote_queue_command(QueueResponse::ResumeRejected).await?; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout)?, + format!("Queued message queued-submission-id for thread {THREAD_ID}.\n") + ); + assert!(String::from_utf8(output.stderr)?.contains("thread already has an active writer")); + Ok(()) +} + #[tokio::test] async fn queue_does_not_fallback_from_unsupported_explicit_remote() -> Result<()> { let (output, _) = run_remote_queue_command(QueueResponse::MethodNotFound).await?; @@ -165,6 +204,7 @@ async fn run_remote_queue_command(response: QueueResponse) -> Result<(Output, Va let listener = TcpListener::bind("127.0.0.1:0").await?; let endpoint = format!("ws://{}", listener.local_addr()?); let remote_first = matches!(response, QueueResponse::MethodNotFound); + let resume = matches!(response, QueueResponse::ResumeRejected); let server_home = codex_home.path().to_path_buf(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await?; @@ -192,6 +232,7 @@ async fn run_remote_queue_command(response: QueueResponse) -> Result<(Output, Va .env("CODEX_HOME", codex_home.path()) .env("CODEX_REMOTE_TOKEN", "test-token") .args(remote_args) + .args(resume.then_some("--resume")) .args(["--thread", THREAD_ID, "--message", "do the thing"]) .output() .await?; @@ -206,7 +247,6 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; - use app_test_support::create_mock_responses_server_sequence_unchecked; use codex_app_server_client::AppServerEvent; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::RemoteAppServerClient; @@ -223,18 +263,28 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; + use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::TurnStatus; use codex_protocol::shell_environment::OPENAI_FEDERATION_RULE_ID_ENV_VAR; use codex_protocol::shell_environment::OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR; + use core_test_support::responses; use tokio::process::Command; use tokio::time::timeout; + use wiremock::Mock; + use wiremock::matchers::method; + use wiremock::matchers::path; let codex_home = socket_test_home()?; let codex = codex_utils_cargo_bin::cargo_bin("codex")?; - let model = create_mock_responses_server_sequence_unchecked(vec![ - create_final_assistant_message_sse_response("queued message consumed")?, - ]) - .await; + let model = responses::start_mock_server().await; + let model_requests = Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(responses::sse_response( + create_final_assistant_message_sse_response("queued message consumed")?, + )) + .expect(2) + .mount_as_scoped(&model) + .await; MockResponsesConfig::new(&model.uri()) .with_root_config("features.plugins = false\nanalytics.enabled = false") .with_provider_config("env_key = \"CODEX_QUEUE_SERVER_API_KEY\"") @@ -294,7 +344,7 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res ) .env_remove("CODEX_QUEUE_SERVER_API_KEY") .kill_on_drop(true) - .args(["queue", "--remote", "unix://"]) + .args(["queue", "--resume", "--remote", "unix://"]) .args(["--thread", &thread_id, "--message", message]) .output(), ) @@ -329,13 +379,9 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res .await?; assert!(queue.data.is_empty()); - let requests = model - .received_requests() - .await - .context("model request capture unavailable")?; + let requests = model_requests.received_requests().await; let request = requests - .iter() - .find(|request| request.url.path().ends_with("/responses")) + .first() .context("queued message did not reach the model")?; assert!( request.body_json::()?["input"] @@ -384,6 +430,7 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res assert!(!output.status.success()); assert!(String::from_utf8(output.stderr)?.contains("cannot confirm session deletion")); + let cold_message = "Continue after the thread was unloaded"; let cases: &[(&[&str], Option)] = &[ (&["archive", name], Some(true)), (&["unarchive", name], Some(false)), @@ -425,11 +472,54 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res }, }) .await?; + let thread = threads.data.iter().find(|thread| thread.id == thread_id); assert_eq!( - threads.data.iter().any(|thread| thread.id == thread_id), + thread.is_some(), expected_archived == Some(archived), "{args:?}: archived={archived}" ); + if expected_archived == Some(false) + && let Some(thread) = thread + { + assert_eq!(thread.status, ThreadStatus::NotLoaded); + } + } + let Some(archived) = expected_archived else { + continue; + }; + // Archived threads are reachable by ID; active threads can also be targeted by name. + let (target, text) = if archived { + (thread_id.as_str(), "still archived") + } else { + (name, cold_message) + }; + let output = remote_command() + .args(["queue", "--resume", "--thread", target, "--message", text]) + .output() + .await?; + if archived { + assert!(!output.status.success()); + assert!( + String::from_utf8(output.stderr)? + .contains(&format!("session {thread_id} is archived")) + ); + } else { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + timeout( + Duration::from_secs(30), + model_requests.wait_until_satisfied(), + ) + .await?; + let requests = model_requests.received_requests().await; + assert!( + requests[1].body_json::()?["input"] + .to_string() + .contains(cold_message) + ); } } Ok(()) diff --git a/codex-rs/tui/src/session_queue_commands.rs b/codex-rs/tui/src/session_queue_commands.rs index 9752a4281737..21181f7336c0 100644 --- a/codex-rs/tui/src/session_queue_commands.rs +++ b/codex-rs/tui/src/session_queue_commands.rs @@ -9,6 +9,9 @@ use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ThreadQueueAddParams; use codex_app_server_protocol::ThreadQueueAddResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::UserInput; use codex_app_server_protocol::experimental_required_message; use codex_protocol::ThreadId; @@ -17,6 +20,9 @@ use color_eyre::Report; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use color_eyre::eyre::eyre; +use serde::Deserialize; +use serde::de::IgnoredAny; +use std::io::Write; use std::path::Path; use uuid::Uuid; @@ -27,6 +33,7 @@ const THREAD_QUEUE_ADD_METHOD: &str = "thread/queue/add"; pub async fn run_session_queue_command( target: String, message: String, + resume: bool, options: SessionArchiveCommandOptions, ) -> Result { if options.cli.no_daemon && options.explicit_remote_endpoint.is_none() { @@ -74,6 +81,12 @@ pub async fn run_session_queue_command( } result => result?, }; + if resume && let Err(err) = resume_if_unloaded(&mut app_server, thread_id).await { + let _ = writeln!( + std::io::stderr().lock(), + "Warning: message was queued, but could not resume thread {thread_id}: {err:#}" + ); + } Ok(format!( "Queued message {} for thread {}.", @@ -127,6 +140,50 @@ pub(super) async fn run_session_queue_action_with_app_server( Ok((thread_id, response)) } +async fn resume_if_unloaded(app_server: &mut AppServerSession, thread_id: ThreadId) -> Result<()> { + if app_server.uses_embedded_app_server() { + return Err(eyre!( + "start a local daemon or specify a server with --remote" + )); + } + // Only decode the status: remote thread metadata can contain paths from another OS. + #[derive(Deserialize)] + struct ReadResponse { + thread: Status, + } + #[derive(Deserialize)] + struct Status { + status: ThreadStatus, + } + + let handle = app_server.request_handle(); + let response: ReadResponse = handle + .request_typed(ClientRequest::ThreadRead { + request_id: app_server.next_request_id(), + params: ThreadReadParams { + thread_id: thread_id.to_string(), + include_turns: false, + }, + }) + .await + .wrap_err_with(|| format!("failed to read thread {thread_id}"))?; + if response.thread.status != ThreadStatus::NotLoaded { + // A fresh loaded thread can accept input before it has any history to resume. + return Ok(()); + } + handle + .request_typed::(ClientRequest::ThreadResume { + request_id: app_server.next_request_id(), + params: ThreadResumeParams { + thread_id: thread_id.to_string(), + exclude_turns: true, + ..Default::default() + }, + }) + .await?; + Ok(()) +} + fn is_unsupported_queue_error(error: &Report) -> bool { matches!( error.downcast_ref::(), From eee977b504b5258f7d15f26e9aba29f5cfd54669 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 26 Oct 2026 07:12:10 +0000 Subject: [PATCH 053/287] Stop dotenv loading on persistent I/O errors (#57233) ## Why Some I/O errors can repeat without advancing the dotenv iterator, causing environment loading to loop indefinitely. ## What changed - Only open the Codex home `.env` path when it is a file. - Stop processing entries on I/O errors other than `InvalidData`, while continuing past invalid UTF-8 lines and parse errors. GitOrigin-RevId: 4b79b5e87b6924b342265e0688ebe59fefea4637 --- codex-rs/arg0/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2af8624b6524..1abf467835e7 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -275,7 +275,9 @@ const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_"; /// with names starting with `CODEX_`. fn load_dotenv() { if let Ok(codex_home) = find_codex_home() - && let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) + && let dotenv_path = codex_home.join(".env") + && dotenv_path.is_file() + && let Ok(iter) = dotenvy::from_path_iter(dotenv_path) { set_filtered(iter); } @@ -286,7 +288,13 @@ fn set_filtered(iter: I) where I: IntoIterator>, { - for (key, value) in iter.into_iter().flatten() { + for result in iter { + let (key, value) = match result { + Ok(entry) => entry, + // read_line consumes invalid UTF-8 lines. Other read errors may repeat. + Err(dotenvy::Error::Io(err)) if err.kind() != std::io::ErrorKind::InvalidData => break, + Err(_) => continue, + }; if !key.to_ascii_uppercase().starts_with(ILLEGAL_ENV_VAR_PREFIX) { // It is safe to call set_var() because our process is // single-threaded at this point in its execution. From e7d32ee040dd6f7d66c9429e192b755a0a3dccd7 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 26 Oct 2026 09:13:33 +0000 Subject: [PATCH 054/287] Prevent credential collisions in registry auth test fixtures (#57234) ## Why The fixture token `new` can appear in randomly generated Base64 public keys, causing the executor registration response's credential check to reject valid test responses. ## What changed Use hyphenated fixture tokens that cannot occur in Base64 keys. Set the retry test's X25519 public key to a value containing `new` so the former collision is exercised deterministically. GitOrigin-RevId: 059043eddc388a69c4659d6cdcac6ee107ae1973 --- codex-rs/exec-server/src/remote/auth_tests.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/codex-rs/exec-server/src/remote/auth_tests.rs b/codex-rs/exec-server/src/remote/auth_tests.rs index 2b11e20d0dbb..c8d0dd210cc6 100644 --- a/codex-rs/exec-server/src/remote/auth_tests.rs +++ b/codex-rs/exec-server/src/remote/auth_tests.rs @@ -18,9 +18,14 @@ use wiremock::matchers::header; use super::*; use crate::NoiseChannelIdentity; +use crate::NoiseChannelPublicKey; use crate::relay::HarnessKeyValidator; use crate::remote::RegistryHarnessKeyValidator; +// Hyphens keep fixture credentials out of randomly generated Base64 public keys. +const OLD_AUTHORIZATION: &str = "Bearer test-old-token"; +const NEW_AUTHORIZATION: &str = "Bearer test-new-token"; + #[derive(Default)] struct RotatingAuth { refreshes: AtomicUsize, @@ -47,9 +52,9 @@ impl RotatingAuth { impl AuthProvider for RotatingAuth { fn add_auth_headers(&self, headers: &mut HeaderMap) -> Result<(), codex_api::AuthError> { let token = if self.refreshes.load(Ordering::SeqCst) == 0 { - "Bearer old" + OLD_AUTHORIZATION } else { - "Bearer new" + NEW_AUTHORIZATION }; headers.insert(AUTHORIZATION, HeaderValue::from_static(token)); Ok(()) @@ -119,13 +124,16 @@ async fn noise_registry_operations_retry_with_new_credentials() -> anyhow::Resul ("validate", RegistryAuthMode::ExecutorCredential), ] { let server = MockServer::start().await; - let key = NoiseChannelIdentity::generate()?.public_key(); - Mock::given(header("authorization", "Bearer old")) + let mut key = serde_json::to_value(NoiseChannelIdentity::generate()?.public_key())?; + // 32-byte Base64-encoded key material containing the former fixture credential `new`. + key["x25519_public_key"] = "newAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".into(); + let key: NoiseChannelPublicKey = serde_json::from_value(key)?; + Mock::given(header("authorization", OLD_AUTHORIZATION)) .respond_with(ResponseTemplate::new(401)) .expect(1) .mount(&server) .await; - Mock::given(header("authorization", "Bearer new")) + Mock::given(header("authorization", NEW_AUTHORIZATION)) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "environment_id": "env", "url": "wss://relay.example/ws", "executor_registration_id": "registration", From f427056ff67af16421ba5e8f1fc7b1f4aba05757 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 26 Oct 2026 09:22:32 +0000 Subject: [PATCH 055/287] Tag exec_command telemetry with the configured shell snapshot mode (#57235) ## Why Commands that fall back without using a shell snapshot should remain associated with their configured snapshot mode in telemetry. ## What changed Add a `shell_snapshot_mode` telemetry tag to `exec_command` with values `disabled`, `v1`, or `v2`. Give `v2` precedence when enabled. The tag records configuration, not whether a command actually used a snapshot. ## Testing Add parameterized coverage for all four combinations of snapshot feature flags using a non-login command, which cannot use V2 snapshots. GitOrigin-RevId: 2dd9e79b9e02448938166254ea1b988034a81b5e --- .../handlers/unified_exec/exec_command.rs | 14 +++++ .../src/tools/handlers/unified_exec_tests.rs | 57 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index c025d534a872..329dc2cfd110 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -29,6 +29,7 @@ use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; +use crate::tools::registry::ToolTelemetryTags; use crate::unified_exec::ExecCommandRequest; use crate::unified_exec::UnifiedExecContext; use crate::unified_exec::UnifiedExecError; @@ -492,6 +493,19 @@ fn one_shot_exec_command_spec(spec: ToolSpec) -> ToolSpec { } impl CoreToolRuntime for ExecCommandHandler { + fn telemetry_tags(&self, invocation: &ToolInvocation) -> ToolTelemetryTags { + // Record the configured mode so fallback calls remain in their rollout cohort. + // This does not indicate whether the command actually used a snapshot. + let mode = if invocation.session.enabled(Feature::ShellSnapshotV2) { + "v2" + } else if invocation.session.enabled(Feature::ShellSnapshot) { + "v1" + } else { + "disabled" + }; + vec![("shell_snapshot_mode", mode.to_string())] + } + fn environments_for_input( &self, invocation: &ToolInvocation, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 20715efbacad..e67f1ba4ad97 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -777,3 +777,60 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ ] ); } + +#[test_case::test_case(false, false, "disabled"; "disabled")] +#[test_case::test_case(true, false, "v1"; "v1")] +#[test_case::test_case(true, true, "v2"; "v2")] +#[test_case::test_case(false, true, "v2"; "v2 without v1")] +#[tokio::test] +async fn exec_command_telemetry_records_configured_snapshot_mode( + v1_enabled: bool, + v2_enabled: bool, + expected_mode: &str, +) { + use crate::session::tests::make_session_and_context_with_auth_and_config_and_rx; + use codex_features::Feature; + + let (session, turn, _events) = make_session_and_context_with_auth_and_config_and_rx( + codex_login::CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| { + for (feature, enabled) in [ + (Feature::ShellSnapshot, v1_enabled), + (Feature::ShellSnapshotV2, v2_enabled), + ] { + if enabled { + config + .features + .enable(feature) + .expect("enable snapshot feature"); + } else { + config + .features + .disable(feature) + .expect("disable snapshot feature"); + } + } + }, + ) + .await; + let invocation = ToolInvocation { + session, + step_context: StepContext::for_test(Arc::clone(&turn)), + turn, + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "snapshot-metric".to_string(), + tool_name: codex_tools::ToolName::plain("exec_command"), + source: ToolCallSource::Direct, + // Non-login commands cannot use V2 but must stay in the configured cohort. + payload: ToolPayload::Function { + arguments: r#"{"cmd":"true","login":false}"#.to_string(), + }, + environments: Default::default(), + }; + assert_eq!( + ExecCommandHandler::default().telemetry_tags(&invocation), + vec![("shell_snapshot_mode", expected_mode.to_string())], + ); +} From 49d6fe5bd0c55ab2fa73ee35f80733ea4be3bc6f Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Mon, 26 Oct 2026 10:06:18 +0000 Subject: [PATCH 056/287] Exercise V2 spawning in subagent context inheritance tests (#57238) ## Why The context inheritance tests enabled `MultiAgentV2` but invoked `spawn_agent` through the V1 namespace, leaving the V2 path untested. ## What changed - Share a helper that spawns through the V2 namespace, waits for child completion, and identifies the child's request by its thread ID. - Assert that the child receives inherited instructions in a `developer` message and its task in an `agent_message` addressed to `/root/worker`. - Test skill instructions both enabled and disabled for parent and child requests, with an explicit skills extension and discovery isolated to the fixture skill. GitOrigin-RevId: 193fca57398e950acad1f020382dc1bae6453df4 --- .../tests/suite/subagent_notifications.rs | 256 +++++++++--------- 1 file changed, 128 insertions(+), 128 deletions(-) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c37557affacd..301242a62763 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1,10 +1,15 @@ use anyhow::Result; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerSource; +use codex_config::ConfigLayerStack; use codex_core::StartThreadOptions; use codex_core::ThreadConfigSnapshot; use codex_core::TurnInputRequest; use codex_core::TurnStartOptions; use codex_core::config::AgentRoleConfig; +use codex_core::config::Config; use codex_core::config::CurrentTimeReminderConfig; +use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; use codex_history::RolloutItem; use codex_models_manager::bundled_models_response; @@ -22,12 +27,14 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use codex_protocol::protocol::Op; +use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentActivityKind; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; +use codex_skills_extension::SkillsExtensionConfig; use codex_thread_store::LoadThreadHistoryParams; use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; @@ -51,6 +58,7 @@ use core_test_support::responses::strip_metadata_from_json; use core_test_support::responses::strip_response_item_ids_from_json; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::local_requests; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; @@ -61,6 +69,7 @@ use serde_json::Value; use serde_json::json; use std::fs; use std::path::Path; +use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; use test_case::test_case; @@ -2189,75 +2198,96 @@ async fn spawned_agent_uses_summary_support_for_final_model( Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Result<()> { - skip_if_no_network!(Ok(())); - +async fn spawn_v2_child_and_capture_requests( + builder: TestCodexBuilder, +) -> Result<(ResponsesRequest, ResponsesRequest)> { let server = start_mock_server().await; - let spawn_args = serde_json::to_string(&json!({ - "message": CHILD_PROMPT, - "task_name": "worker", - }))?; - mount_sse_once_match( - &server, - |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + let test = builder + .with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("enable collab"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("enable V2 spawn"); + }) + .build_with_auto_env(&server) + .await?; + let mut spawn = ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &json!({ "message": CHILD_PROMPT, "task_name": "worker" }).to_string(), + ); + spawn["item"]["encrypted_function_args"] = json!([]); + // The parent and child may request their final answers in either order. + let finish_turn = |id| { sse(vec![ - ev_response_created("resp-turn1-1"), - ev_function_call_with_namespace( - SPAWN_CALL_ID, - MULTI_AGENT_V1_NAMESPACE, - "spawn_agent", - &spawn_args, - ), - ev_completed("resp-turn1-1"), - ]), - ) - .await; - - let child_request_log = mount_sse_once_match( + ev_response_created(id), + ev_assistant_message(id, "Done."), + ev_completed(id), + ]) + }; + let responses = mount_sse_sequence( &server, - |req: &wiremock::Request| { - body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) - }, - sse(vec![ - ev_response_created("resp-child-1"), - ev_completed("resp-child-1"), - ]), + vec![ + sse(vec![ + ev_response_created("spawn"), + spawn, + ev_completed("spawn"), + ]), + finish_turn("done-1"), + finish_turn("done-2"), + ], ) .await; - let _turn1_followup = mount_sse_once_match( - &server, - |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), - sse(vec![ - ev_response_created("resp-turn1-2"), - ev_assistant_message("msg-turn1-2", "parent done"), - ev_completed("resp-turn1-2"), - ]), - ) + let mut created_threads = test.thread_manager.subscribe_thread_created(); + test.submit_turn(TURN_1_PROMPT).await?; + let child_id = created_threads.try_recv().expect("spawn created a child"); + let child = test.thread_manager.get_thread(child_id).await?; + wait_for_event(child.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) .await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Collab) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - config.developer_instructions = Some("Parent developer instructions.".to_string()); - }); - let test = builder.build(&server).await?; + let mut requests = responses.requests().into_iter(); + let parent_request = requests.next().expect("parent's initial request"); + let child_request = requests + .find(|request| request.header("thread-id") == Some(child_id.to_string())) + .expect("request from the spawned child"); + Ok((parent_request, child_request)) +} - test.submit_turn(TURN_1_PROMPT).await?; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Result<()> { + skip_if_no_network!(Ok(())); - let child_requests = wait_for_requests(&child_request_log).await?; - let child_request = child_requests - .last() - .expect("child request log should capture at least one request"); - assert!(child_request.body_contains_text("Parent developer instructions.")); - assert!(child_request.body_contains_text(CHILD_PROMPT)); + const INSTRUCTIONS: &str = "Parent developer instructions."; + let builder = test_codex().with_config(|config| { + config.developer_instructions = Some(INSTRUCTIONS.to_string()); + }); + let (_, child_request) = spawn_v2_child_and_capture_requests(builder).await?; + assert!( + child_request + .message_input_texts("developer") + .iter() + .any(|text| text.contains(INSTRUCTIONS)) + ); + let task = child_request + .inputs_of_type("agent_message") + .into_iter() + .find(|item| item["recipient"] == "/root/worker") + .expect("task addressed to the child"); + let content = task["content"].as_array().expect("task content"); + assert!(content.iter().any(|part| { + part["type"] == "input_text" + && part["text"] + .as_str() + .is_some_and(|text| text.contains(CHILD_PROMPT)) + })); Ok(()) } @@ -3287,82 +3317,52 @@ async fn multi_agent_v2_peer_followup_completion_notifies_initiating_turn( Ok(()) } +#[test_case(true; "enabled")] +#[test_case(false; "disabled")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> { +async fn skill_instructions_in_parent_and_spawned_child(include_instructions: bool) -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_mock_server().await; - let spawn_args = serde_json::to_string(&json!({ - "message": CHILD_PROMPT, - "task_name": "worker", - }))?; - let spawn_turn = mount_sse_once_match( - &server, - |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), - sse(vec![ - ev_response_created("resp-turn1-1"), - ev_function_call_with_namespace( - SPAWN_CALL_ID, - MULTI_AGENT_V1_NAMESPACE, - "spawn_agent", - &spawn_args, - ), - ev_completed("resp-turn1-1"), - ]), - ) - .await; - - let child_request_log = mount_sse_once_match( - &server, - |req: &wiremock::Request| { - body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) - }, - sse(vec![ - ev_response_created("resp-child-1"), - ev_completed("resp-child-1"), - ]), - ) - .await; - - let _turn1_followup = mount_sse_once_match( - &server, - |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), - sse(vec![ - ev_response_created("resp-turn1-2"), - ev_assistant_message("msg-turn1-2", "parent done"), - ev_completed("resp-turn1-2"), - ]), - ) - .await; - - let mut builder = test_codex() + let mut extensions = ExtensionRegistryBuilder::new(); + codex_skills_extension::install(&mut extensions, |config: &Config| SkillsExtensionConfig { + include_instructions: config.include_skill_instructions, + max_context_tokens: config.skill_max_context_tokens, + bundled_skills_enabled: false, + cloud_skill_enabled: false, + shadow_selection_enabled: false, + }); + let builder = test_codex() + .with_extensions(Arc::new(extensions.build())) .with_pre_build_hook(|home| { write_home_skill(home, "demo", "demo-skill", "demo skill").expect("write home skill"); }) - .with_config(|config| { - config - .features - .enable(Feature::Collab) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - config.include_skill_instructions = false; + .with_config(move |config| { + config.include_skill_instructions = include_instructions; + // Discover only the fixture's skill, independently of the user's ~/.agents/skills. + config.config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::System { + file: config.codex_home.join("config.toml"), + }, + toml::Table::new().into(), + )], + Default::default(), + Default::default(), + ) + .expect("valid test config layer"); }); - let test = builder.build(&server).await?; - - test.submit_turn(TURN_1_PROMPT).await?; - let parent_request = spawn_turn.single_request(); - assert!(!parent_request.body_contains_text("")); - assert!(!parent_request.body_contains_text("demo-skill")); - - let child_requests = wait_for_requests(&child_request_log).await?; - let child_request = child_requests - .last() - .expect("child request log should capture at least one request"); - assert!(!child_request.body_contains_text("")); - assert!(!child_request.body_contains_text("demo-skill")); + let (parent_request, child_request) = spawn_v2_child_and_capture_requests(builder).await?; + for request in [parent_request, child_request] { + if include_instructions { + assert!(request.message_input_texts("developer").iter().any(|text| { + text.contains(SKILLS_INSTRUCTIONS_OPEN_TAG) + && text.contains("- demo-skill: demo skill") + })); + } else { + assert!(!request.body_contains_text(SKILLS_INSTRUCTIONS_OPEN_TAG)); + assert!(!request.body_contains_text("demo-skill")); + } + } Ok(()) } From 3d8ca703aa3d86888d77f82319a7a740e00550fc Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 26 Oct 2026 10:10:57 +0000 Subject: [PATCH 057/287] Make the MCP HTTP probe timeout test deterministic (#57239) ## Why The timeout fallback test depends on short wall-clock deadlines and a server-side sleep, making its timing dependent on thread scheduling. ## What changed Use a paused Tokio clock and advance past the timeout only after the server receives `HEAD`. Keep the blocking server task alive until the probe consumes the `GET` response to prevent automatic clock advancement during socket I/O. Assert both request lines and retain the check that the fallback returns `HTTP 405`. GitOrigin-RevId: da7b2fa9c67e662648da8568ce7f8cfde153dc12 --- codex-rs/cli/src/doctor.rs | 48 +++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 8f2f915481b2..fd9b250ed265 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -3912,35 +3912,51 @@ mod tests { assert_eq!(status, Ok("HTTP 405".to_string())); } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn mcp_http_probe_falls_back_to_get_when_head_times_out() { + use std::io::BufRead as _; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); let addr = listener.local_addr().expect("listener address"); - let server = std::thread::spawn(move || { + let (head_received_tx, head_received_rx) = tokio::sync::oneshot::channel(); + let (probe_done_tx, probe_done_rx) = std::sync::mpsc::channel::<()>(); + // A blocking task inhibits Tokio's automatic clock advancement while real socket I/O + // is pending. Keep it alive until the client has consumed the GET response, too. + let server = tokio::task::spawn_blocking(move || { let (mut head_stream, _) = listener.accept().expect("accept HEAD probe request"); - let head = std::thread::spawn(move || { - let mut request = [0; 1024]; - let _ = head_stream.read(&mut request); - std::thread::sleep(Duration::from_millis(50)); - }); + let mut head_request = String::new(); + std::io::BufReader::new(&mut head_stream) + .read_line(&mut head_request) + .expect("read HEAD request line"); + assert_eq!(head_request, "HEAD /mcp HTTP/1.1\r\n"); + head_received_tx.send(()).expect("signal HEAD receipt"); let (mut get_stream, _) = listener.accept().expect("accept GET probe request"); - let mut request = [0; 1024]; - let _ = get_stream.read(&mut request); + let mut get_request = String::new(); + std::io::BufReader::new(&mut get_stream) + .read_line(&mut get_request) + .expect("read GET request line"); + assert_eq!(get_request, "GET /mcp HTTP/1.1\r\n"); get_stream .write_all( b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ) .expect("write response"); - head.join().expect("HEAD holder should finish"); + // Dropping the sender also releases this wait if the probe panics. + let _ = probe_done_rx.recv(); + drop(head_stream); }); - let status = mcp_http_probe_url_with_timeout( - &format!("http://{addr}/mcp"), - Duration::from_millis(10), - ) - .await; - server.join().expect("probe server thread should finish"); + let url = format!("http://{addr}/mcp"); + let probe = mcp_http_probe_url_with_timeout(&url, Duration::from_millis(10)); + let expire_head = async { + head_received_rx.await.expect("server should receive HEAD"); + // Cross the unchanged 10 ms deadline only after HEAD is waiting for a response. + tokio::time::advance(Duration::from_millis(11)).await; + }; + let (status, ()) = tokio::join!(probe, expire_head); + drop(probe_done_tx); + server.await.expect("probe server task should finish"); assert_eq!(status, Ok("HTTP 405".to_string())); } From 333d7bde0fb3f7299e758dd75a7d4405e8b541ed Mon Sep 17 00:00:00 2001 From: dhruvgupta-oai Date: Mon, 26 Oct 2026 12:01:21 +0000 Subject: [PATCH 058/287] Bound memory baseline files and recover incomplete snapshots (#57240) ## Why Memory baseline snapshots need bounded file reads, and recovery must keep existing memory files visible to consolidation rather than treating them as already processed. ## What changed - Limit baseline files to 10 MiB, skip oversized working files, and check stored blob sizes before reading them. - Version baselines and replace missing, outdated, or unusable metadata with an empty baseline, preserving working files as additions. Also invalidate the baseline when a tracked file grows beyond the limit. - Serialize baseline initialization, resets, and diffs with a cross-process lock. Isolate repository access and pin index writes to the baseline worktree. ## Testing Add coverage for oversized files, legacy baseline migration, interrupted resets, cross-process locking, and consolidation with unchanged inputs after baseline recovery. GitOrigin-RevId: 34ecc9e5700a88ed37eff83a90f08d8a9b0d9f29 --- codex-rs/git-utils/src/baseline.rs | 380 +++++++++++++----- .../git-utils/src/baseline_recovery_tests.rs | 119 ++++++ codex-rs/memories/write/src/startup_tests.rs | 40 ++ codex-rs/memories/write/src/workspace.rs | 10 +- .../memories/write/src/workspace_tests.rs | 8 +- 5 files changed, 461 insertions(+), 96 deletions(-) create mode 100644 codex-rs/git-utils/src/baseline_recovery_tests.rs diff --git a/codex-rs/git-utils/src/baseline.rs b/codex-rs/git-utils/src/baseline.rs index 3dc36d797861..6ab104372c9f 100644 --- a/codex-rs/git-utils/src/baseline.rs +++ b/codex-rs/git-utils/src/baseline.rs @@ -6,14 +6,23 @@ use gix::objs::tree::EntryKind; use gix::objs::tree::EntryMode; use similar::TextDiff; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::ffi::OsStr; use std::fs; +use std::io::Read; +#[cfg(windows)] +use std::os::windows::fs::FileTypeExt; use std::path::Path; use std::path::PathBuf; use tokio::task; use crate::operations::run_git_for_status; +// Match the default file limit for automatic ghost snapshots. A baseline is derived data. +const MAX_BASELINE_FILE_BYTES: u64 = 10 * 1024 * 1024; +const BASELINE_VERSION_FILE: &str = "codex-baseline-version"; +const BASELINE_VERSION: &[u8] = b"1\n"; + const BASELINE_COMMIT_MESSAGE: &str = "Initialize Codex git baseline\n\nCo-authored-by: Codex "; @@ -67,96 +76,182 @@ struct GitBaselineFileEntry { /// This is intentionally destructive for `root/.git`. It is meant for internal directories where /// git is used only as a baseline/diff implementation detail, not for user repositories. pub async fn reset_git_repository(root: &Path) -> anyhow::Result<()> { - let root = root.to_path_buf(); - task::spawn_blocking(move || reset_git_repository_sync(&root)).await? + with_baseline_lock(root, |root| { + reset_git_repository_sync(root, BaselineContents::Current) + }) + .await } -/// Ensures `root` has a usable git baseline repository. +/// Ensures the internal directory has a usable, size-bounded baseline. /// -/// Existing usable `.git/` metadata is preserved. Missing or unusable metadata is replaced with a -/// fresh one-commit baseline. +/// Old or incomplete baselines are discarded without reading their blobs. The replacement is +/// empty so all eligible working files remain visible to the next consolidation as additions. pub async fn ensure_git_baseline_repository(root: &Path) -> anyhow::Result<()> { - let root = root.to_path_buf(); - task::spawn_blocking(move || { - fs::create_dir_all(&root) - .with_context(|| format!("create git baseline root {}", root.display()))?; - if root.join(".git").is_dir() - && let Ok(repo) = gix::open(&root) - && head_file_entries(&repo).is_ok() - { + with_baseline_lock(root, ensure_git_baseline_repository_sync).await +} + +fn ensure_git_baseline_repository_sync(root: &Path) -> anyhow::Result<()> { + let git_dir = root.join(".git"); + let mut version = Vec::new(); + // Never follow a gitfile or directory symlink into another repository. + if fs::symlink_metadata(&git_dir).is_ok_and(|metadata| metadata.is_dir()) + && fs::File::open(git_dir.join(BASELINE_VERSION_FILE)) + .and_then(|file| { + file.take(BASELINE_VERSION.len() as u64 + 1) + .read_to_end(&mut version) + }) + .is_ok() + && version == BASELINE_VERSION + && let Ok(repo) = gix::open::Options::isolated().open(&git_dir) + { + let repo = repo.to_thread_local(); + if repo.common_dir() == repo.git_dir() && head_file_entries(&repo).is_ok() { return Ok(()); } - reset_git_repository_sync(&root) - }) - .await? -} - -fn reset_git_repository_sync(root: &Path) -> anyhow::Result<()> { - fs::create_dir_all(root) - .with_context(|| format!("create git baseline root {}", root.display()))?; - remove_git_metadata(root)?; - let repo = gix::init(root).with_context(|| format!("init git repo {}", root.display()))?; - commit_current_tree(&repo, BASELINE_COMMIT_MESSAGE)?; - write_index_from_head(root)?; - Ok(()) + } + reset_git_repository_sync(root, BaselineContents::Empty) } -/// Returns the diff between the latest baseline reset and the current directory contents. -pub async fn diff_since_latest_init(root: &Path) -> anyhow::Result { - let root = root.to_path_buf(); +// Imports and background consolidation can overlap, including across app-server processes. +// A nested .git keeps the lock outside both the replaceable baseline and memory scans. +async fn with_baseline_lock( + root: &Path, + operation: impl FnOnce(&Path) -> anyhow::Result + Send + 'static, +) -> anyhow::Result { + let root = root.to_owned(); task::spawn_blocking(move || { - let repo = gix::open(&root).with_context(|| format!("open git repo {}", root.display()))?; - let head_entries = head_file_entries(&repo)?; - let current_entries = current_file_entries(&repo, &root)?; - let changes = diff_entries(&head_entries, ¤t_entries); - let unified_diff = - render_unified_diff(&repo, &root, &head_entries, ¤t_entries, &changes)?; - Ok(GitBaselineDiff { - changes, - unified_diff, - }) + fs::create_dir_all(&root)?; + let root = fs::canonicalize(root)?; + let control = baseline_control(&root); + for dir in [root.join(".codex-baseline"), control.clone()] { + match fs::create_dir(&dir) { + Ok(()) => {} + Err(err) + if err.kind() == std::io::ErrorKind::AlreadyExists + && fs::symlink_metadata(&dir)?.is_dir() + && fs::canonicalize(&dir)? == dir => {} + Err(err) => return Err(err.into()), + } + } + anyhow::ensure!( + !control.join("HEAD").try_exists()?, + "baseline control directory contains a repository" + ); + let lock = fs::File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(control.join("codex-baseline.lock"))?; + lock.lock().context("lock git baseline")?; + operation(&root) }) .await? } -fn remove_git_metadata(root: &Path) -> anyhow::Result<()> { - let git_path = root.join(".git"); - let metadata = match fs::symlink_metadata(&git_path) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(err).with_context(|| format!("stat {}", git_path.display())), - }; +fn baseline_control(root: &Path) -> PathBuf { + root.join(".codex-baseline").join(".git") +} - if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() { - fs::remove_dir_all(&git_path).with_context(|| format!("remove {}", git_path.display())) - } else { - fs::remove_file(&git_path).with_context(|| format!("remove {}", git_path.display())) +fn discard_baseline_path(path: &Path) -> anyhow::Result<()> { + match path.symlink_metadata() { + #[cfg(windows)] + Ok(meta) if meta.file_type().is_symlink_dir() => fs::remove_dir(path)?, + Ok(meta) if meta.is_dir() => { + // Invalidate before deletion: interruption must not leave trusted partial metadata. + match fs::remove_file(path.join(BASELINE_VERSION_FILE)) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + fs::remove_dir_all(path)?; + } + Ok(_) => fs::remove_file(path)?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), } + Ok(()) } -fn commit_current_tree(repo: &gix::Repository, message: &str) -> anyhow::Result<()> { - let root = repo - .workdir() - .context("git baseline repo must have a worktree")?; - let tree_id = write_tree(repo, root)?; +enum BaselineContents { + Empty, + Current, +} + +fn reset_git_repository_sync(root: &Path, contents: BaselineContents) -> anyhow::Result<()> { + let git_dir = root.join(".git"); + // Only metadata is disposable. An interrupted reset leaves no version marker, so the next + // operation retries with an empty baseline rather than trusting a partial snapshot. + discard_baseline_path(&git_dir)?; + let repo = gix::ThreadSafeRepository::init_opts( + root, + gix::create::Kind::WithWorktree, + gix::create::Options::default(), + gix::open::Options::isolated(), + )? + .to_thread_local(); + let tree_id = match contents { + BaselineContents::Empty => repo.write_object(Tree::default())?.detach(), + BaselineContents::Current => write_tree(&repo, root)?, + }; let signature = codex_signature(); let mut time = gix::date::parse::TimeBuf::default(); - let signature_ref = signature.to_ref(&mut time); + let signature = signature.to_ref(&mut time); repo.commit_as( - signature_ref, - signature_ref, + signature, + signature, "HEAD", - message, + BASELINE_COMMIT_MESSAGE, tree_id, Vec::::new(), - ) - .context("commit git baseline repo")?; + )?; + write_index_from_head(root)?; + fs::write(git_dir.join(BASELINE_VERSION_FILE), BASELINE_VERSION)?; Ok(()) } fn write_index_from_head(root: &Path) -> anyhow::Result<()> { - run_git_for_status(root, ["read-tree", "--reset", "HEAD"], /*env*/ None) - .context("write git baseline index from HEAD") + // Pin the index write to this worktree. Relative paths also work with Git for Windows + // when Rust has canonicalized the working directory to a verbatim path. + run_git_for_status( + root, + ["read-tree", "--reset", "HEAD"], + Some(&[ + ("GIT_DIR".into(), ".git".into()), + ("GIT_COMMON_DIR".into(), ".git".into()), + ("GIT_INDEX_FILE".into(), ".git/index".into()), + ("GIT_OBJECT_DIRECTORY".into(), ".git/objects".into()), + ("GIT_WORK_TREE".into(), ".".into()), + ]), + )?; + Ok(()) +} + +/// Returns the diff between the latest baseline and the current directory's bounded files. +pub async fn diff_since_latest_init(root: &Path) -> anyhow::Result { + with_baseline_lock(root, |root| { + ensure_git_baseline_repository_sync(root)?; + let mut repo = gix::open::Options::isolated().open(root)?.to_thread_local(); + let mut head_entries = head_file_entries(&repo)?; + let mut current_entries = BTreeMap::new(); + let mut excluded = BTreeSet::new(); + collect_current_entries(&repo, root, root, &mut current_entries, &mut excluded)?; + // Inputs may grow during synchronization, after the initial baseline was prepared. + if excluded.iter().any(|path| head_entries.contains_key(path)) { + drop(repo); + reset_git_repository_sync(root, BaselineContents::Empty)?; + repo = gix::open::Options::isolated().open(root)?.to_thread_local(); + head_entries.clear(); + } + let changes = diff_entries(&head_entries, ¤t_entries); + let unified_diff = + render_unified_diff(&repo, root, &head_entries, ¤t_entries, &changes)?; + Ok(GitBaselineDiff { + changes, + unified_diff, + }) + }) + .await } fn codex_signature() -> gix::actor::Signature { @@ -195,13 +290,15 @@ fn write_tree(repo: &gix::Repository, dir: &Path) -> anyhow::Result { oid, }); } else if file_type.is_file() { - let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let Some((bytes, mode)) = read_baseline_file(&path)? else { + continue; + }; let oid = repo .write_blob(bytes) .with_context(|| format!("write blob {}", path.display()))? .detach(); entries.push(Entry { - mode: file_mode(&path, EntryKind::Blob)?, + mode, filename: os_str_to_bstring(&file_name), oid, }); @@ -264,20 +361,12 @@ fn collect_tree_entries( Ok(()) } -fn current_file_entries( - repo: &gix::Repository, - root: &Path, -) -> anyhow::Result> { - let mut entries = BTreeMap::new(); - collect_current_entries(repo, root, root, &mut entries)?; - Ok(entries) -} - fn collect_current_entries( repo: &gix::Repository, root: &Path, dir: &Path, entries: &mut BTreeMap, + excluded: &mut BTreeSet, ) -> anyhow::Result<()> { for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { let entry = entry?; @@ -288,14 +377,17 @@ fn collect_current_entries( let file_type = entry.file_type()?; if file_type.is_dir() { - collect_current_entries(repo, root, &path, entries)?; + collect_current_entries(repo, root, &path, entries, excluded)?; } else if file_type.is_file() { - let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let Some((bytes, mode)) = read_baseline_file(&path)? else { + excluded.insert(relative_slash_path(root, &path)?); + continue; + }; entries.insert( relative_slash_path(root, &path)?, GitBaselineFileEntry { oid: blob_oid(repo, &bytes)?, - mode: file_mode(&path, EntryKind::Blob)?, + mode, }, ); } else if file_type.is_symlink() { @@ -313,6 +405,21 @@ fn collect_current_entries( Ok(()) } +/// Bounds the read even if a file grows between checking its size and reaching EOF. +fn read_baseline_file(path: &Path) -> anyhow::Result, EntryMode)>> { + let file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?; + let metadata = file.metadata()?; + let len = metadata.len(); + if len > MAX_BASELINE_FILE_BYTES { + return Ok(None); + } + // Reserve an EOF probe byte so bounded reads of stable files need no reallocation. + let mut bytes = Vec::with_capacity(len as usize + 1); + file.take(MAX_BASELINE_FILE_BYTES + 1) + .read_to_end(&mut bytes)?; + Ok((bytes.len() as u64 <= MAX_BASELINE_FILE_BYTES).then_some((bytes, file_mode(&metadata)))) +} + fn blob_oid(repo: &gix::Repository, bytes: &[u8]) -> anyhow::Result { gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, bytes) .context("compute git baseline blob oid") @@ -431,6 +538,10 @@ fn render_change_diff( } fn read_head_blob(repo: &gix::Repository, entry: &GitBaselineFileEntry) -> anyhow::Result> { + anyhow::ensure!( + repo.find_header(entry.oid)?.size() <= MAX_BASELINE_FILE_BYTES, + "baseline blob exceeds the snapshot file limit" + ); let mut blob = repo.find_blob(entry.oid)?; Ok(blob.take_data()) } @@ -444,7 +555,9 @@ fn read_current_file_bytes(root: &Path, relative_path: &str) -> anyhow::Result &'static str { } #[cfg(unix)] -fn file_mode(path: &Path, default: EntryKind) -> anyhow::Result { +fn file_mode(metadata: &fs::Metadata) -> EntryMode { use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(path)?.permissions().mode(); - Ok(if mode & 0o111 == 0 { - default.into() + if metadata.permissions().mode() & 0o111 == 0 { + EntryKind::Blob.into() } else { EntryKind::BlobExecutable.into() - }) + } } #[cfg(not(unix))] -fn file_mode(_path: &Path, default: EntryKind) -> anyhow::Result { - Ok(default.into()) +fn file_mode(_metadata: &fs::Metadata) -> EntryMode { + EntryKind::Blob.into() } #[cfg(unix)] @@ -525,6 +637,10 @@ fn path_to_slash_string(path: &Path) -> String { .join("/") } +#[cfg(test)] +#[path = "baseline_recovery_tests.rs"] +mod recovery_tests; + #[cfg(test)] mod tests { use super::*; @@ -533,7 +649,7 @@ mod tests { use std::process::Command; use tempfile::TempDir; - fn git_stdout(root: &Path, args: &[&str]) -> String { + pub(super) fn git_stdout(root: &Path, args: &[&str]) -> String { let output = Command::new("git") .current_dir(root) .args(args) @@ -555,6 +671,12 @@ mod tests { fs::create_dir_all(&root).expect("create root"); fs::write(root.join("MEMORY.md"), "baseline").expect("write memory"); + let oversized = root.join("large-output"); + fs::File::create(&oversized) + .expect("create large file") + .set_len(MAX_BASELINE_FILE_BYTES + 1) + .expect("size large file"); + reset_git_repository(&root).await.expect("reset repo"); assert!(root.join(".git").is_dir()); @@ -562,8 +684,77 @@ mod tests { let diff = diff_since_latest_init(&root).await.expect("diff"); assert!(!diff.has_changes()); assert_eq!(diff.unified_diff, ""); - assert_eq!(git_stdout(&root, &["status", "--porcelain"]), ""); + assert_eq!( + git_stdout(&root, &["status", "--porcelain"]), + "?? large-output\n" + ); assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n"); + + // An older baseline could already contain the oversized blob. + git_stdout(&root, &["add", "large-output"]); + git_stdout( + &root, + &[ + "-c", + "user.name=Codex", + "-c", + "user.email=codex@example.com", + "-c", + "commit.gpgSign=false", + "commit", + "-m", + "Legacy baseline", + ], + ); + fs::remove_file(root.join(".git").join(BASELINE_VERSION_FILE)).expect("remove version"); + fs::write(root.join("MEMORY.md"), "pending edit").expect("edit memory"); + ensure_git_baseline_repository(&root) + .await + .expect("migrate legacy baseline"); + assert_eq!( + git_stdout(&root, &["ls-tree", "-r", "--name-only", "HEAD"]), + "" + ); + assert_eq!(git_stdout(&root, &["ls-files"]), ""); + let diff = diff_since_latest_init(&root) + .await + .expect("diff after migration"); + assert_eq!( + diff.changes, + vec![GitBaselineChange { + status: GitBaselineChangeStatus::Added, + path: "MEMORY.md".to_string(), + }] + ); + assert!(diff.unified_diff.contains("+pending edit")); + assert_eq!( + fs::metadata(oversized).expect("worktree file").len(), + MAX_BASELINE_FILE_BYTES + 1 + ); + + // Phase 2 may grow a tracked input after preparation and before computing its diff. + fs::write(root.join("memory_summary.md"), "summary").expect("write summary"); + reset_git_repository(&root) + .await + .expect("reset bounded baseline"); + fs::File::options() + .write(true) + .open(root.join("MEMORY.md")) + .expect("open tracked") + .set_len(MAX_BASELINE_FILE_BYTES + 1) + .expect("grow tracked"); + let diff = diff_since_latest_init(&root) + .await + .expect("diff after input synchronization"); + assert_eq!( + diff.changes, + vec![GitBaselineChange { + status: GitBaselineChangeStatus::Added, + path: "memory_summary.md".to_string(), + }] + ); + assert_eq!(git_stdout(&root, &["ls-files"]), ""); + assert_eq!(git_stdout(&root, &["diff", "HEAD", "--stat"]), ""); } #[tokio::test] @@ -579,9 +770,18 @@ mod tests { .expect("ensure repo"); let diff = diff_since_latest_init(&root).await.expect("diff"); - assert!(!diff.has_changes()); - assert_eq!(git_stdout(&root, &["status", "--porcelain"]), ""); - assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n"); + assert_eq!( + diff.changes, + vec![GitBaselineChange { + status: GitBaselineChangeStatus::Added, + path: "MEMORY.md".to_string(), + }] + ); + assert_eq!( + git_stdout(&root, &["status", "--porcelain"]), + "?? MEMORY.md\n" + ); + assert_eq!(git_stdout(&root, &["ls-files"]), ""); } #[cfg(unix)] diff --git a/codex-rs/git-utils/src/baseline_recovery_tests.rs b/codex-rs/git-utils/src/baseline_recovery_tests.rs new file mode 100644 index 000000000000..8b55a0c24ea2 --- /dev/null +++ b/codex-rs/git-utils/src/baseline_recovery_tests.rs @@ -0,0 +1,119 @@ +//! Exercises cross-process serialization and retrying an interrupted baseline reset. + +use super::baseline_control; +use super::diff_since_latest_init; +use super::ensure_git_baseline_repository; +use super::reset_git_repository; +use super::tests::git_stdout; +use super::with_baseline_lock; +use anyhow::Result; +use pretty_assertions::assert_eq; +use std::fs; +use std::io; +use std::io::BufRead; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +#[tokio::test] +async fn concurrent_operations_share_the_workspace_lock() -> Result<()> { + const CHILD_ROOT: &str = "CODEX_TEST_BASELINE_LOCK_ROOT"; + const READY: &str = "holding workspace lock"; + if let Some(root) = std::env::var_os(CHILD_ROOT) { + return with_baseline_lock(Path::new(&root), |_| { + println!("{READY}"); + io::stdout().flush()?; + io::stdin().read_exact(&mut [0])?; + Ok(()) + }) + .await; + } + + let workspace = tempfile::tempdir()?; + let root = workspace.path(); + fs::write(root.join("MEMORY.md"), "original\n")?; + reset_git_repository(root).await?; + fs::write(root.join("MEMORY.md"), "updated\n")?; + + let mut child = codex_utils_process::background_command(std::env::current_exe()?) + .args([ + "--exact", + "baseline::recovery_tests::concurrent_operations_share_the_workspace_lock", + "--nocapture", + ]) + .env(CHILD_ROOT, root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + let stdout = io::BufReader::new(child.stdout.as_mut().expect("piped stdout")); + assert!( + stdout + .lines() + .any(|line| line.is_ok_and(|line| line.contains(READY))), + "child exited before acquiring the lock" + ); + let lock = fs::File::options() + .read(true) + .write(true) + .open(baseline_control(root).join("codex-baseline.lock"))?; + let contended = matches!(lock.try_lock(), Err(fs::TryLockError::WouldBlock)); + drop(lock); + + let reset = reset_git_repository(root); + let ensure = ensure_git_baseline_repository(root); + let diff = diff_since_latest_init(root); + tokio::pin!(reset, ensure, diff); + // Any one operation completing before the holder releases the lock is a regression. + let completed_while_locked = tokio::select! { + _ = &mut reset => Some("reset"), + _ = &mut ensure => Some("ensure"), + _ = &mut diff => Some("diff"), + () = tokio::time::sleep(Duration::from_millis(500)) => None, + }; + child.stdin.take().expect("piped stdin").write_all(b"x")?; + assert!(child.wait()?.success(), "lock holder failed"); + assert!(contended, "another process must own the lock"); + assert_eq!(completed_while_locked, None); + + let ((), (), diff) = tokio::try_join!(reset, ensure, diff)?; + if diff.has_changes() { + assert!(diff.unified_diff.contains("-original\n+updated")); + } + assert_eq!(git_stdout(root, &["show", "HEAD:MEMORY.md"]), "updated\n"); + assert_eq!(git_stdout(root, &["show", ":MEMORY.md"]), "updated\n"); + Ok(()) +} + +#[tokio::test] +async fn interrupted_reset_replays_working_files() -> Result<()> { + let workspace = tempfile::tempdir()?; + let root = workspace.path(); + let memory = root.join("MEMORY.md"); + fs::write(&memory, "original\n")?; + reset_git_repository(root).await?; + fs::write(&memory, "pending edit\n")?; + + // Simulate exiting after committing the tree but before writing the index and version. + fs::remove_file(root.join(".git").join(super::BASELINE_VERSION_FILE))?; + fs::remove_file(root.join(".git/index"))?; + ensure_git_baseline_repository(root).await?; + ensure_git_baseline_repository(root).await?; + assert_eq!( + git_stdout(root, &["ls-tree", "-r", "--name-only", "HEAD"]), + "" + ); + assert_eq!(git_stdout(root, &["ls-files"]), ""); + assert_eq!(fs::read_to_string(&memory)?, "pending edit\n"); + let diff = diff_since_latest_init(root).await?; + assert_eq!( + diff.changes, + vec![super::GitBaselineChange { + status: super::GitBaselineChangeStatus::Added, + path: "MEMORY.md".to_string(), + }] + ); + assert!(diff.unified_diff.contains("+pending edit")); + Ok(()) +} diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 0167dfc6a82a..612b3ac98446 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -441,6 +441,46 @@ async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> anyhow:: Ok(()) } +#[tokio::test] +async fn phase2_replays_legacy_baseline_with_unchanged_inputs() -> anyhow::Result<()> { + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + let db = init_state_db(&home).await?; + let root = home.path().join("memories"); + db.memories() + .enqueue_global_consolidation(/*input_watermark*/ 1) + .await?; + sync_rollout_summaries_from_memories(&root, &[], /*max_raw_memories_for_consolidation*/ 0) + .await?; + rebuild_raw_memories_file_from_memories( + &root, + &[], + /*max_raw_memories_for_consolidation*/ 0, + ) + .await?; + seed_extension_instructions(&root).await?; + seed_required_memory_artifacts(&root).await?; + reset_git_repository(&root).await?; + assert!(!diff_since_latest_init(&root).await?.has_changes()); + tokio::fs::remove_file(root.join(".git/codex-baseline-version")).await?; + + let phase2 = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-phase2-replay"), + ev_assistant_message("msg-phase2-replay", "phase2 complete"), + ev_completed("resp-phase2-replay"), + ]), + ) + .await; + let test = build_test_codex(&server, home.clone()).await?; + trigger_memories_startup(&test).await; + wait_for_single_request(&phase2).await; + wait_for_phase2_workspace_reset(db.memories(), &root).await?; + shutdown_test_codex(&test).await?; + Ok(()) +} + #[tokio::test] async fn phase2_retries_when_clean_workspace_is_missing_artifacts() -> anyhow::Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/memories/write/src/workspace.rs b/codex-rs/memories/write/src/workspace.rs index 76b9fda9ada2..917e0c6d5f44 100644 --- a/codex-rs/memories/write/src/workspace.rs +++ b/codex-rs/memories/write/src/workspace.rs @@ -10,9 +10,9 @@ use std::path::Path; /// Prepares the memory directory for git-baseline diffing. /// -/// This keeps an existing usable `.git/` baseline intact. It initializes a new git baseline when the -/// metadata is missing or unusable, and removes any stale generated `phase2_workspace_diff.md` file -/// so that the next diff does not include a previous prompt artifact. +/// This keeps an existing current-version `.git/` baseline intact. Missing, outdated or unusable +/// metadata is replaced with an empty baseline so existing memory files are reconsidered. Any stale +/// generated `phase2_workspace_diff.md` is removed so it does not enter the next diff. pub async fn prepare_memory_workspace(root: &Path) -> anyhow::Result<()> { crate::ensure_layout(root) .await @@ -24,8 +24,8 @@ pub async fn prepare_memory_workspace(root: &Path) -> anyhow::Result<()> { /// Returns the current workspace diff after removing any stale generated diff artifact. /// -/// The removed file is only `phase2_workspace_diff.md`; memory artifacts and `.git/` metadata are -/// left intact. +/// Memory artifacts are left intact. The baseline is invalidated if a tracked file has grown +/// beyond the snapshot size limit since preparation. pub async fn memory_workspace_diff(root: &Path) -> anyhow::Result { remove_workspace_diff(root).await?; diff_since_latest_init(root).await diff --git a/codex-rs/memories/write/src/workspace_tests.rs b/codex-rs/memories/write/src/workspace_tests.rs index 96ac98f5f1af..f9793ae0c7ce 100644 --- a/codex-rs/memories/write/src/workspace_tests.rs +++ b/codex-rs/memories/write/src/workspace_tests.rs @@ -107,7 +107,13 @@ async fn prepare_memory_workspace_recovers_unusable_git_dir() { let diff = memory_workspace_diff(&root) .await .expect("load workspace diff"); - assert_eq!(diff.changes, Vec::new()); + assert_eq!( + diff.changes, + vec![GitBaselineChange { + status: GitBaselineChangeStatus::Added, + path: "MEMORY.md".to_string(), + }] + ); } #[test] From b88173048752cd971fd75a722afaeaad52c40e59 Mon Sep 17 00:00:00 2001 From: ioanbudea-OAI Date: Mon, 26 Oct 2026 12:26:11 +0000 Subject: [PATCH 059/287] Honor configured message limits for encrypted guardian transcripts (#57241) Remove the hard-coded 9,000-token cap for encrypted agent messages in both synchronous and asynchronous guardian context. Use `message_tokens` directly when converting the configured limit to a byte budget, allowing larger messages when the configured limit permits them. Messages that exceed the configured budget still produce an omission notice; encrypted content remains intact when retained. GitOrigin-RevId: a9032ca19102ebad44be8df1ac4d4892d4404ead --- codex-rs/guardian-context/src/profile.rs | 6 ++---- codex-rs/guardian-context/src/transcript.rs | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/codex-rs/guardian-context/src/profile.rs b/codex-rs/guardian-context/src/profile.rs index df58ff3dbde3..779807ba0f59 100644 --- a/codex-rs/guardian-context/src/profile.rs +++ b/codex-rs/guardian-context/src/profile.rs @@ -316,10 +316,8 @@ fn prepare_sync_content<'a>( TranscriptContent::Record(record) => record.text(), TranscriptContent::AgentMessage(_) => { // Native ciphertext stays whole, or leaves an omission at the same position. - let limit = codex_protocol::protocol::TruncationPolicy::Tokens( - limits.message_tokens.min(/*other*/ 9_000), - ) - .byte_budget(); + let limit = codex_protocol::protocol::TruncationPolicy::Tokens(limits.message_tokens) + .byte_budget(); return if entry.original_bytes > limit { std::borrow::Cow::Owned(TranscriptContent::Text( crate::transcript::TRANSCRIPT_OMISSION_NOTICE.to_owned(), diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index 56a2ea2a1070..50872f4b52a7 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -217,7 +217,7 @@ fn collect_transcript_for_target( TranscriptContent::AgentMessage(Box::new(item.clone())) } else { let limit = codex_protocol::protocol::TruncationPolicy::Tokens( - config.entry_limits.message_tokens.min(9_000), + config.entry_limits.message_tokens, ) .byte_budget(); if bytes <= limit { From db7516f41bec71d26ddefc6af29e68cac9ba580c Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 26 Oct 2026 12:32:19 +0000 Subject: [PATCH 060/287] Reduce the default thread unload delay to one minute (#57242) ## What changed Change the default `thread_unload_delay_secs` from 1800 to 60 seconds. App-server threads become eligible for unloading after one minute with no subscribers and no activity. Update the configuration documentation, schema, and thread manager sample to match. ## Testing Update the configuration test to cover the 60-second default, zero for immediate unloading, and an explicit 1800-second override. GitOrigin-RevId: 0da8454eb25a6ef4b7e3665601982d2e0c183f7c --- codex-rs/config/src/config_toml.rs | 2 +- codex-rs/core/config.schema.json | 2 +- codex-rs/core/src/config/config_tests.rs | 4 ++-- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/thread-manager-sample/src/main.rs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 98c02e6e5c15..dedf0aea6d83 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -349,7 +349,7 @@ pub struct ConfigToml { pub background_terminal_max_timeout: Option, /// Seconds a thread must have no subscribers and no activity before app-server - /// unloads it. Defaults to 1800 (30 minutes); zero unloads immediately. + /// unloads it. Defaults to 60 (1 minute); zero unloads immediately. /// Resolved when each thread is loaded; request config overrides apply per thread. pub thread_unload_delay_secs: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3dd9740369bb..d15af0114b24 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -8495,7 +8495,7 @@ "type": "boolean" }, "thread_unload_delay_secs": { - "description": "Seconds a thread must have no subscribers and no activity before app-server unloads it. Defaults to 1800 (30 minutes); zero unloads immediately. Resolved when each thread is loaded; request config overrides apply per thread.", + "description": "Seconds a thread must have no subscribers and no activity before app-server unloads it. Defaults to 60 (1 minute); zero unloads immediately. Resolved when each thread is loaded; request config overrides apply per thread.", "format": "uint64", "minimum": 0.0, "type": "integer" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 97217c5ce669..52d68896c2a6 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -258,9 +258,9 @@ async fn load_config_applies_optional_mcp_startup_grace() -> std::io::Result<()> async fn load_config_resolves_thread_unload_delay() -> anyhow::Result<()> { let codex_home = tempdir()?; for (toml, seconds) in [ - ("", 1800), + ("", 60), ("thread_unload_delay_secs = 0", 0), - ("thread_unload_delay_secs = 60", 60), + ("thread_unload_delay_secs = 1800", 1800), ] { let config = Config::load_from_base_config_with_overrides( toml::from_str(toml)?, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index feb68b25234f..c2f12e60e594 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -4122,7 +4122,7 @@ impl Config { .unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS) .max(MIN_EMPTY_YIELD_TIME_MS); let thread_unload_delay = - Duration::from_secs(cfg.thread_unload_delay_secs.unwrap_or(/*default*/ 1800)); + Duration::from_secs(cfg.thread_unload_delay_secs.unwrap_or(/*default*/ 60)); if std::time::Instant::now() .checked_add(thread_unload_delay) .is_none() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 47792f00acd6..56deb71fbc25 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -383,7 +383,7 @@ async fn new_config( tool_registry: Default::default(), code_mode: Default::default(), background_terminal_max_timeout: 300_000, - thread_unload_delay: std::time::Duration::from_secs(/*secs*/ 1800), + thread_unload_delay: std::time::Duration::from_secs(/*secs*/ 60), ghost_snapshot: GhostSnapshotConfig::default(), multi_agent_v2: MultiAgentV2Config::default(), max_goal_token_budget: None, From 7a9625f3eec687329bf75ea5ab2b3b718edb59ca Mon Sep 17 00:00:00 2001 From: goodman-oai Date: Mon, 26 Oct 2026 13:09:22 +0000 Subject: [PATCH 061/287] Pause Tokio time in startup draft handoff tests (#57243) Run the deferred paste newline and standalone Enter handoff tests with `start_paused = true` so their timing uses Tokio's paused clock. GitOrigin-RevId: d903c0e1078b81531912e54819499b0c3a0fb307 --- codex-rs/tui/src/startup_draft_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs index 03a76ab4306b..5e977f15a4d9 100644 --- a/codex-rs/tui/src/startup_draft_tests.rs +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -471,7 +471,7 @@ async fn startup_draft_preserves_non_bracketed_multiline_pastes_without_submitti assert!(pump.app_event_rx.try_recv().is_err()); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn startup_draft_preserves_deferred_paste_newlines_across_handoff() { let (event_tx, event_rx) = unbounded_channel(); let mut pump = startup_test_pump(std::iter::empty()); @@ -511,7 +511,7 @@ async fn startup_draft_preserves_deferred_paste_newlines_across_handoff() { assert_eq!(pump.into_draft().text, "first line\ns"); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn startup_draft_does_not_turn_a_standalone_enter_into_a_newline_at_handoff() { let (_event_tx, event_rx) = unbounded_channel(); let mut pump = startup_test_pump(std::iter::empty()); From cc6baf9c32c6149108fa8ec855d2b731e3ff14a7 Mon Sep 17 00:00:00 2001 From: goodman-oai Date: Mon, 26 Oct 2026 13:10:45 +0000 Subject: [PATCH 062/287] Wait for process completion in the unified exec reuse test (#57244) Replace the fixed 200 ms sleep in `reusing_completed_process_returns_unknown_process` with polling until `write_stdin` reports no process ID, bounded by a 10-second timeout. This ensures the shell has completed before asserting that reusing its process ID returns `UnknownProcessId`. GitOrigin-RevId: c752e9c2c0a419fa5a06ba957bee49dcaeb5d6d2 --- codex-rs/core/src/unified_exec/mod_tests.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 4bc5847102eb..5623048df1ff 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -553,12 +553,17 @@ async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<( .await?; let process_id = open_shell.process_id.expect("expected process id"); - write_stdin( - &session, &turn, process_id, "exit\n", /*yield_time_ms*/ 2_500, - ) - .await?; - - tokio::time::sleep(Duration::from_millis(200)).await; + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async { + let mut response = write_stdin( + &session, &turn, process_id, "exit\n", /*yield_time_ms*/ 2_500, + ) + .await?; + while response.process_id.is_some() { + response = write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 100).await?; + } + anyhow::Ok(()) + }) + .await??; let err = write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 100) .await From e84504b2c716ec16018a396b0a7b7026387ac9ac Mon Sep 17 00:00:00 2001 From: goodman-oai Date: Mon, 26 Oct 2026 13:18:41 +0000 Subject: [PATCH 063/287] Stabilize the rejected queue message test (#57245) ## Why Each turn runs an external hook, making a shared ten-second deadline too restrictive. Turn completion can also precede deletion of the queued item. ## What changed Use the event waits' progress deadlines in the rejected-message test and wait for the queue to empty before checking results, replacing the immediate empty-queue assertion. GitOrigin-RevId: 2b417840156893d829dc2984aa88b06a5a13aefe --- codex-rs/ext/queue/tests/queue_service.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/codex-rs/ext/queue/tests/queue_service.rs b/codex-rs/ext/queue/tests/queue_service.rs index 305b8ed6adb9..e46ae8bec9d9 100644 --- a/codex-rs/ext/queue/tests/queue_service.rs +++ b/codex-rs/ext/queue/tests/queue_service.rs @@ -754,17 +754,16 @@ async fn rejected_queue_messages_are_consumed_without_retrying_or_blocking_follo } let queue = install_registered_queue(&test, installed.as_ref())?; - tokio::time::timeout(Duration::from_secs(10), async { - test.submit_text_turn("A").await?; - for _ in 0..2 { - wait_for_event_match(test.codex.as_ref(), |event| { - matches!(event, EventMsg::TurnComplete(_)).then_some(()) - }) - .await; - } - anyhow::Ok(()) - }) - .await??; + // Each turn runs an external hook. Use the event waits' progress deadlines + // instead of sharing one wall-clock deadline across all three turns. + test.submit_text_turn("A").await?; + for _ in 0..2 { + wait_for_event_match(test.codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + } + wait_for_queue_empty(&queue, thread_id).await?; let prompts = responses .requests() @@ -782,7 +781,6 @@ async fn rejected_queue_messages_are_consumed_without_retrying_or_blocking_follo vec!["A", "blocked", "C"], hook_log.lines().collect::>() ); - assert!(queue.list(thread_id).await?.is_empty()); assert_eq!(2, responses.requests().len()); Ok(()) } From 12b2b58e6310e4f5d4041d62d2266ecc3f538d7d Mon Sep 17 00:00:00 2001 From: goodman-oai Date: Mon, 26 Oct 2026 13:20:07 +0000 Subject: [PATCH 064/287] Release the rollout maintenance lock when its guard is dropped (#57247) ## Why A duplicated or inherited file handle can keep the rollout maintenance lock held after its guard is dropped, blocking subsequent compression or migration. ## What changed Explicitly unlock the file in `RolloutMaintenanceGuard::drop` so lock ownership ends with the guard even if another handle remains open. ## Testing Add a regression test that retains a duplicated file handle, verifies that the lock stays held until the last shared guard is dropped, and checks that dropping the old handle does not release a newly acquired lock. GitOrigin-RevId: 19005680dbc0a01b36c6f13e4ec358c6f00b75a0 --- codex-rs/rollout/src/maintenance.rs | 15 +++++++++-- codex-rs/rollout/src/maintenance_tests.rs | 32 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 codex-rs/rollout/src/maintenance_tests.rs diff --git a/codex-rs/rollout/src/maintenance.rs b/codex-rs/rollout/src/maintenance.rs index 35b792874096..1e0a0862db2c 100644 --- a/codex-rs/rollout/src/maintenance.rs +++ b/codex-rs/rollout/src/maintenance.rs @@ -15,9 +15,20 @@ use std::path::Path; const ROLLOUT_MAINTENANCE_LOCK: &str = "rollout-maintenance.lock"; +#[cfg(test)] +#[path = "maintenance_tests.rs"] +mod tests; + /// Holds exclusive ownership of operations that replace local rollout files. pub struct RolloutMaintenanceGuard { - _file: File, + file: File, +} + +impl Drop for RolloutMaintenanceGuard { + fn drop(&mut self) { + // Release the lock even if a child inherited the file before exec. + let _ = self.file.unlock(); + } } /// Try to exclude rollout compression and migration for one Codex home. @@ -34,7 +45,7 @@ pub fn try_acquire_rollout_maintenance_lock( .open(directory.join(ROLLOUT_MAINTENANCE_LOCK))?; match file.try_lock() { - Ok(()) => Ok(Some(RolloutMaintenanceGuard { _file: file })), + Ok(()) => Ok(Some(RolloutMaintenanceGuard { file })), Err(std::fs::TryLockError::WouldBlock) => Ok(None), Err(std::fs::TryLockError::Error(error)) => Err(error), } diff --git a/codex-rs/rollout/src/maintenance_tests.rs b/codex-rs/rollout/src/maintenance_tests.rs new file mode 100644 index 000000000000..1c9d880baec8 --- /dev/null +++ b/codex-rs/rollout/src/maintenance_tests.rs @@ -0,0 +1,32 @@ +//! Tests maintenance ownership across shared guards and duplicated file handles. + +use std::io; +use std::sync::Arc; + +use tempfile::TempDir; + +use super::try_acquire_rollout_maintenance_lock; + +#[test] +fn dropping_last_guard_releases_lock_with_duplicated_handle() -> io::Result<()> { + let home = TempDir::new()?; + let guard = Arc::new( + try_acquire_rollout_maintenance_lock(home.path())?.expect("acquire maintenance lock"), + ); + let retained_guard = Arc::clone(&guard); + let duplicated_file = guard.file.try_clone()?; + + assert!(try_acquire_rollout_maintenance_lock(home.path())?.is_none()); + drop(guard); + assert!(try_acquire_rollout_maintenance_lock(home.path())?.is_none()); + + drop(retained_guard); + let next_guard = try_acquire_rollout_maintenance_lock(home.path())? + .expect("dropping the last guard releases the lock despite the duplicated handle"); + + drop(duplicated_file); + assert!(try_acquire_rollout_maintenance_lock(home.path())?.is_none()); + drop(next_guard); + assert!(try_acquire_rollout_maintenance_lock(home.path())?.is_some()); + Ok(()) +} From 8f71a8657ec99683df4d1aea4806229398ef87ca Mon Sep 17 00:00:00 2001 From: goodman-oai Date: Mon, 26 Oct 2026 13:26:06 +0000 Subject: [PATCH 065/287] Make login token-refresh tests wait for auth publication (#57248) ## Why The same-owner token-refresh tests need the original backend response to remain pending until `getAuthStatus` publishes refreshed credentials. A fixed response delay does not guarantee that ordering. ## What changed Gate responses in the `getAuthStatus` test cases until a request-specific auth reload log confirms the credential change. Emit that log after updating the cached auth and generation, and include `auth_changed_for_refresh` as a structured field. Extend the JSON log test helper to match events with predicates, allowing tests to identify the refresh by its request span. GitOrigin-RevId: 698f3be499ea3c035f082c501836dae4178adaef --- .../app-server/tests/common/json_logging.rs | 26 ++---- .../tests/common/test_app_server.rs | 19 ++++- codex-rs/app-server/tests/suite/v2/account.rs | 84 +++++++++++++++++-- codex-rs/login/src/auth/manager.rs | 5 +- 4 files changed, 105 insertions(+), 29 deletions(-) diff --git a/codex-rs/app-server/tests/common/json_logging.rs b/codex-rs/app-server/tests/common/json_logging.rs index 6a854e668813..4ee86290498b 100644 --- a/codex-rs/app-server/tests/common/json_logging.rs +++ b/codex-rs/app-server/tests/common/json_logging.rs @@ -28,31 +28,15 @@ impl JsonLogCapture { pub(crate) async fn wait_for_event( &self, - event_name: &str, + description: &str, + predicate: impl Fn(&Value) -> bool, timeout: Duration, ) -> Result { - let mut events = self - .wait_for_events(event_name, /*count*/ 1, timeout) - .await?; - Ok(events.remove(0)) - } - - pub(crate) async fn wait_for_events( - &self, - event_name: &str, - count: usize, - timeout: Duration, - ) -> Result> { let result = tokio::time::timeout(timeout, async { loop { let updated = self.updated.notified(); - let events = self - .events()? - .into_iter() - .filter(|event| event["fields"]["event.name"].as_str() == Some(event_name)) - .collect::>(); - if events.len() >= count { - return Ok(events); + if let Some(event) = self.events()?.into_iter().find(&predicate) { + return Ok(event); } updated.await; } @@ -67,7 +51,7 @@ impl JsonLogCapture { .unwrap_or_else(std::sync::PoisonError::into_inner) .join("\n"); anyhow::bail!( - "timed out waiting for {count} JSON log event(s) named `{event_name}`; captured stderr:\n{lines}" + "timed out waiting for a JSON log event {description}; captured stderr:\n{lines}" ) } } diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 1e688ae71887..bec590955bbb 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -250,7 +250,24 @@ impl TestAppServer { event_name: &str, timeout: Duration, ) -> anyhow::Result { - self.json_logs.wait_for_event(event_name, timeout).await + self.wait_for_matching_json_log_event( + &format!("named `{event_name}`"), + |event| event["fields"]["event.name"].as_str() == Some(event_name), + timeout, + ) + .await + } + + /// Waits for a JSON stderr event matching a predicate, including its request span. + pub async fn wait_for_matching_json_log_event( + &self, + description: &str, + predicate: impl Fn(&serde_json::Value) -> bool, + timeout: Duration, + ) -> anyhow::Result { + self.json_logs + .wait_for_event(description, predicate, timeout) + .await } async fn new_with_program_env_and_args( diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 9b08b833fccc..aa09b38f94d5 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -9,6 +9,12 @@ use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::encode_id_token; use app_test_support::write_chatgpt_auth; use app_test_support::write_models_cache; +use axum::Router; +use axum::body::Body; +use axum::http::HeaderMap; +use axum::http::Uri; +use axum::http::header::HOST; +use axum::routing::get; use chrono::Duration as ChronoDuration; use chrono::Utc; use codex_app_server_protocol::Account; @@ -61,8 +67,11 @@ use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; use test_case::test_case; +use tokio::net::TcpListener; use tokio::sync::Notify; use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use url::Url; use wiremock::Mock; use wiremock::MockServer; @@ -2296,11 +2305,53 @@ async fn login_survives_same_owner_token_refresh( ) -> Result<()> { let codex_home = TempDir::new()?; let backend = MockServer::start().await; + let release_response = CancellationToken::new(); + let (chatgpt_base_url, _response_gate) = match refresh_trigger { + LoginRefreshTrigger::AuthStatus => { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let base_url = format!("http://{}/backend-api", listener.local_addr()?); + let upstream = backend.uri(); + let gated_path = delayed_path.to_string(); + let release = release_response.clone(); + let client = HttpClientBuilder::new().build_direct()?; + // Keep Wiremock's matching and request recording, but hold the response + // asynchronously so the token refresh can use the same mock backend. + let router = Router::new().fallback(get(move |uri: Uri, mut headers: HeaderMap| { + let upstream = upstream.clone(); + let gated_path = gated_path.clone(); + let release = release.clone(); + let client = client.clone(); + async move { + let hold_response = uri.path() == gated_path + && headers + .get("authorization") + .is_some_and(|value| value == "Bearer access-token-123"); + headers.remove(HOST); + let response = client + .get(format!("{upstream}{uri}")) + .headers(headers) + .send() + .await + .expect("mock backend response"); + if hold_response { + release.cancelled().await; + } + response.into_http_response().map(Body::new) + } + })); + let task = + AbortOnDropHandle::new(tokio::spawn( + async move { axum::serve(listener, router).await }, + )); + (base_url, Some(task)) + } + LoginRefreshTrigger::UnauthorizedConfig => (format!("{}/backend-api", backend.uri()), None), + }; create_config_toml( codex_home.path(), CreateConfigTomlParams { requires_openai_auth: Some(true), - chatgpt_base_url: Some(format!("{}/backend-api", backend.uri())), + chatgpt_base_url: Some(chatgpt_base_url), ..Default::default() }, )?; @@ -2352,11 +2403,11 @@ async fn login_survives_same_owner_token_refresh( .and(header("authorization", "Bearer access-token-123")) .respond_with(move |_: &wiremock::Request| { request_started.notify_one(); - ResponseTemplate::new(match refresh_trigger { - LoginRefreshTrigger::AuthStatus => 200, - LoginRefreshTrigger::UnauthorizedConfig => 401, - }) - .set_delay(Duration::from_secs(/*secs*/ 2)) + match refresh_trigger { + LoginRefreshTrigger::AuthStatus => ResponseTemplate::new(200), + LoginRefreshTrigger::UnauthorizedConfig => ResponseTemplate::new(401) + .set_delay(Duration::from_secs(/*secs*/ 2)), + } .set_body_json(response.clone()) }) .with_priority(1) @@ -2368,6 +2419,7 @@ async fn login_survives_same_owner_token_refresh( let issuer = backend.uri(); let refresh_url = format!("{issuer}/oauth/refresh"); let mut server = TestAppServer::builder() + .with_json_logging("warn,codex_login::auth::manager=info,codex_app_server=info") .with_codex_home(codex_home.path()) .with_env_overrides(&[ ("OPENAI_API_KEY", None), @@ -2404,6 +2456,26 @@ async fn login_survives_same_owner_token_refresh( refresh_token: Some(true), }) .await?; + // getAuthStatus reloads config before replying, which can wait for + // this held response. The reload log records the credential change + // after publishing the refreshed auth cache and its generation. + let request_id = request.to_string(); + server + .wait_for_matching_json_log_event( + "auth cache refresh in getAuthStatus", + |event| { + event["fields"]["auth_changed_for_refresh"] == true + && event["spans"].as_array().is_some_and(|spans| { + spans.iter().any(|span| { + span["rpc.method"] == "getAuthStatus" + && span["rpc.request_id"] == request_id + }) + }) + }, + DEFAULT_READ_TIMEOUT, + ) + .await?; + release_response.cancel(); let refreshed: GetAuthStatusResponse = timeout(DEFAULT_READ_TIMEOUT, server.read_response(request)).await??; assert_eq!( diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 7b8c7ffb5f99..9da4328ec7a7 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -2918,7 +2918,6 @@ impl AuthManager { if auth_changed_for_refresh { guard.permanent_refresh_failure = None; } - tracing::info!("Reloaded auth, changed: {changed}"); guard.auth = new_auth; if auth_changed_for_refresh { self.auth_change_state_tx.send_modify(|state| { @@ -2935,6 +2934,10 @@ impl AuthManager { }); self.auth_change_tx.send_modify(|revision| *revision += 1); } + tracing::info!( + auth_changed_for_refresh, + "Reloaded auth, changed: {changed}" + ); changed } From 3ad642e520f76ccec0cbb5964ce605629751ebff Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Mon, 26 Oct 2026 14:04:24 +0000 Subject: [PATCH 066/287] Allow disabling plugin discovery prefetch at app-server startup (#57249) ## Why Hosts that only need installed plugin inventory should be able to skip startup requests for plugin discovery. ## What changed Add the stable, default-enabled `features.plugin_discovery_prefetch` flag. Setting it to `false` skips startup prefetching of the plugin directory, featured plugins, and recommendations while preserving installed plugin cache refreshes and bundle downloads. ## Testing Update app-server integration coverage to verify that installed plugin bundles download with discovery prefetch disabled and that no directory, featured, or suggested plugin requests are made during startup. Also cover listing remote marketplaces with the flag disabled. GitOrigin-RevId: 7cc3072f6045131b9f1acb851f6240db75ac173e --- codex-rs/app-server/src/message_processor.rs | 2 ++ .../request_processors/account_processor.rs | 1 + .../app-server/tests/suite/v2/plugin_list.rs | 18 +++++++++++++++-- codex-rs/core-plugins/src/manager.rs | 20 +++++++++++-------- codex-rs/core-plugins/src/manager_tests.rs | 1 + codex-rs/core/config.schema.json | 6 ++++++ codex-rs/features/src/lib.rs | 9 +++++++++ 7 files changed, 47 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3162839f08ce..14ef60000b6e 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -83,6 +83,7 @@ use codex_core::config::Config; use codex_core::config::ThreadStoreConfig; use codex_exec_server::EnvironmentManager; use codex_extension_api::TurnStartAdmission; +use codex_features::Feature; use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; use codex_home::CodexHomeUserInstructionsProvider; @@ -568,6 +569,7 @@ impl MessageProcessor { .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( &config.plugins_config_input(), + config.features.enabled(Feature::PluginDiscoveryPrefetch), reload_config, Some(on_effective_plugins_changed), startup_warnings_tx, diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 90b5ef4af9db..cf4c6406bcb6 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -308,6 +308,7 @@ impl AccountRequestProcessor { .maybe_start_remote_plugin_caches_refresh( &plugins_config, auth, + /*prefetch_recommendations*/ true, Some(on_effective_plugins_changed), ); } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index c03c4228187d..956cb5d65213 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1961,7 +1961,8 @@ enabled = true } #[tokio::test] -async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { +async fn app_server_startup_downloads_installed_plugin_bundles_without_discovery_prefetch() +-> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( @@ -2001,11 +2002,24 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> .await; mount_empty_user_installed_plugins(&server).await; + for endpoint in [ + "/backend-api/ps/plugins/list", + "/backend-api/plugins/featured", + "/backend-api/ps/plugins/suggested/codex", + ] { + Mock::given(path(endpoint)) + .respond_with(ResponseTemplate::new(404)) + .expect(0) + .mount(&server) + .await; + } + let installed_path = codex_home .path() .join("plugins/cache/openai-curated-remote/linear/1.2.3"); let _mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) + .with_args(&["-c", "features.plugin_discovery_prefetch=false"]) .with_plugin_startup_tasks() .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) @@ -2137,7 +2151,7 @@ enum ProjectPluginConfiguration { Invalid, } -#[test_case(ProjectPluginConfiguration::None, None, "", true, true; "without project override")] +#[test_case(ProjectPluginConfiguration::None, None, "plugin_discovery_prefetch = false", true, true; "without discovery prefetch")] #[test_case(ProjectPluginConfiguration::Disabled, None, "", true, true; "project disables local plugins")] #[test_case(ProjectPluginConfiguration::Invalid, None, "", true, true; "invalid project preserves remote plugins")] #[test_case(ProjectPluginConfiguration::None, Some("tpp"), "", true, true; "configured product sku")] diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 6d9d676258fc..4c299c32fc4a 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1588,6 +1588,7 @@ impl PluginsManager { self: &Arc, config: &PluginsConfigInput, auth: Option, + prefetch_recommendations: bool, on_effective_plugins_changed: Option, ) { self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( @@ -1598,6 +1599,9 @@ impl PluginsManager { EffectivePluginsChange::default(), ); + if !prefetch_recommendations { + return; + } let manager = Arc::clone(self); let config = config.clone(); tokio::spawn(async move { @@ -1873,6 +1877,7 @@ impl PluginsManager { self.maybe_start_remote_plugin_caches_refresh( &context.global_config, auth.clone(), + /*prefetch_recommendations*/ true, on_effective_plugins_changed.clone(), ); self.maybe_start_remote_installed_plugin_bundle_sync( @@ -2874,6 +2879,7 @@ impl PluginsManager { pub fn maybe_start_plugin_startup_tasks_for_config( self: &Arc, config: &PluginsConfigInput, + prefetch_discovery: bool, reload_config: ConfigLayerReload, on_effective_plugins_changed: Option, upgrade_warnings: mpsc::UnboundedSender, @@ -2966,6 +2972,7 @@ impl PluginsManager { manager.maybe_start_remote_plugin_caches_refresh( &config_for_remote_sync, auth.clone(), + prefetch_discovery, on_effective_plugins_changed.clone(), ); manager.maybe_start_remote_installed_plugin_bundle_sync( @@ -2973,6 +2980,9 @@ impl PluginsManager { auth.clone(), on_effective_plugins_changed, ); + if !prefetch_discovery { + return; + } let mut scopes = crate::remote::cached_remote_plugin_catalog_scopes( manager.codex_home.as_path(), &remote_plugin_service_config(&config_for_remote_sync), @@ -2985,18 +2995,12 @@ impl PluginsManager { } manager.maybe_start_remote_catalog_cache_refresh( &config_for_remote_sync, - auth, + auth.clone(), scopes, RemoteCatalogCacheRefreshMode::Force, ); - }); - - let config_for_featured_plugins = config.clone(); - let manager = Arc::clone(self); - tokio::spawn(async move { - let auth = manager.auth_manager.auth().await; if let Err(err) = manager - .featured_plugin_ids_for_config(&config_for_featured_plugins, auth.as_ref()) + .featured_plugin_ids_for_config(&config_for_remote_sync, auth.as_ref()) .await { warn!( diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 39ac515871bf..7439f7b03850 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -6147,6 +6147,7 @@ plugins = true manager.maybe_start_remote_plugin_caches_refresh( &config, Some(auth.clone()), + /*prefetch_recommendations*/ true, /*on_effective_plugins_changed*/ None, ); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d15af0114b24..cc12557fcbc0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1197,6 +1197,9 @@ "personality": { "type": "boolean" }, + "plugin_discovery_prefetch": { + "type": "boolean" + }, "plugin_hooks": { "type": "boolean" }, @@ -7818,6 +7821,9 @@ "personality": { "type": "boolean" }, + "plugin_discovery_prefetch": { + "type": "boolean" + }, "plugin_hooks": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 83daf42efc74..1fdbb4bf5a9d 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -351,6 +351,9 @@ pub enum Feature { ComputerUse, /// Enable the PS-backed remote plugin catalog. RemotePlugin, + /// Prefetch the plugin directory, featured plugins, and recommendations at app-server startup. + /// Inventory-only hosts can disable this. Legacy suggestions read the cached directory. + PluginDiscoveryPrefetch, /// Enable remote plugin sharing flows. PluginSharing, /// Removed compatibility flag retained as a no-op. @@ -1816,6 +1819,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::PluginDiscoveryPrefetch, + key: "plugin_discovery_prefetch", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::PluginSharing, key: "plugin_sharing", From f358635d8e9341c46def8a89372fc03f6bffec56 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 26 Oct 2026 14:41:47 +0000 Subject: [PATCH 067/287] Show native dollar thread usage for ENT26 plans (#57251) ## Why ENT26 plans are billed directly in dollars, so credit estimates and credit-weighted breakdowns do not describe their thread usage. ## What changed - Expose optional `nativeUsageUsdMicros` in app-server `ThreadUsage` responses and generated schemas and SDK types. Missing native usage means unknown, not zero. - Enable ENT26 usage fetching and display native dollar amounts in `/status`, the status line, terminal titles, and the agents overview. Hide credits and omit credit-weighted `/status` breakdowns for these plans. - Use native dollar amounts when tracking post-turn usage settlement for ENT26 plans. ## Testing Add coverage for native usage forwarding, ENT26 billing displays, and settlement polling with stale, missing, or zero dollar amounts. GitOrigin-RevId: ca8ea98141a66bdf0a27feb9f58f67822b821f5f --- .../codex_app_server_protocol.schemas.json | 8 ++ .../codex_app_server_protocol.v2.schemas.json | 8 ++ .../json/v2/GetAccountTokenUsageResponse.json | 8 ++ .../app-server-exports-experimental.json.zst | Bin 168556 -> 168649 bytes .../app-server-exports-stable.json.zst | Bin 161181 -> 161130 bytes .../schema/typescript/v2/ThreadUsage.ts | 6 +- codex-rs/app-server-protocol/src/export.rs | 2 + .../src/protocol/v2/thread_usage.rs | 5 + .../request_processors/account_processor.rs | 1 + .../tests/suite/v2/account_thread_usage.rs | 15 ++- .../tui/src/app/agents_overview_details.rs | 7 +- codex-rs/tui/src/app/agents_overview_usage.rs | 16 ++- .../src/app/agents_overview_usage_tests.rs | 43 ++++++- codex-rs/tui/src/app/background_requests.rs | 10 +- codex-rs/tui/src/app/tests/thread_usage.rs | 1 + .../tui/src/chatwidget/status_controls.rs | 3 +- .../tui/src/chatwidget/status_surfaces.rs | 4 +- .../src/chatwidget/tests/status_and_layout.rs | 18 +++ .../chatwidget/tests/status_command_tests.rs | 10 +- .../tests/status_surface_previews.rs | 2 + codex-rs/tui/src/chatwidget/thread_usage.rs | 17 +-- .../tui/src/chatwidget/thread_usage_tests.rs | 112 +++++++++++++----- codex-rs/tui/src/status/card.rs | 6 +- codex-rs/tui/src/status/mod.rs | 1 + codex-rs/tui/src/status/thread_usage.rs | 19 +++ .../src/openai_codex/generated/v2_all.py | 7 ++ 26 files changed, 271 insertions(+), 58 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c185563d7c1e..3d72909115b3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -25899,6 +25899,14 @@ }, "type": "array" }, + "nativeUsageUsdMicros": { + "description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a5663a8c0632..5505864acc5c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -23613,6 +23613,14 @@ }, "type": "array" }, + "nativeUsageUsdMicros": { + "description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json index a7e85aa8b46b..dbfafb5dac0e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json @@ -76,6 +76,14 @@ }, "type": "array" }, + "nativeUsageUsdMicros": { + "description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 208990a25881a4e91aa506a392a4075ce7a2536f..841bde32dd280ecbd2c4aec0c8db9788520505eb 100644 GIT binary patch delta 123592 zcmV(rK<>ZnqzcKU3b4Acf8@UbT1Ti1FK}%-{Qv*|f3E(+*DdZ8iFQg#G>MQTvL!yk zF;aART9~#e_-5lbqT*h9AE4^Ix&Xcay#Nqusy_*;l=?lhR=##?>B9B}uy~3r|rr1r}6*bInmeA+oEbUE<>R!f) z_LbjyJe;jX`a-#;zeu|jaSxLm?C)}IpCSCx+^u6Wwf12!QcIU25AN53=S*hCkkZtj z{dtYs0&QjMov|rme6uj;&8ms~nG=?ve^gBYQTSHk$J(!pfO2^521Ke`TD#*Rc%b#|__J(NlXh ze>y@7eSz(-md7G8m}e;KSoEMq5DXOW{Aho4s^Z|*^iNU(FhDV{1M85fh*9!c;YUoz z^+;;7a6OD=heF$73;Lv1CE}R29{pBcX<#HZK>1pBGdKKR7o_@f3Ty!xX)HXj@EUuu-zecK+xJ=7ekHKy7Jya6TUQ7C^(KIyhNg<8l=m? zF!gWX)FjDnV_q8H|I#QI8mfvYDHRhClf1A@I5_7{O_vsOg8SAP!09>egT-=OSNqk@ zn)an|A03M<2X8F>E=VAN5FUE;x9p61vva+d`vMMar3#C;L_ zewEA8K&h;VT^`PJ?2SdOzEcv#Tc#|RR51P^)XSG2?DhEd)Uan^T`M~gX=6KZ6pBy<$P(+H1d>CTHm+qqfjm zgvNs1e;qC5sHeKx;u?cHB{3M4OmOA162DOL_`IT{;|Q!$SfOtEm+bG=kRkN=qZWa< z?V#Qqk670ElgOy_n`}n|0FVGfFaQ7)FhC|4h~x2aSU$^sM1TSijSL(QgcSs%Ffxb` z5Cg|xB^ZN%0FM9w%OKzsaD)-C2Rr3^8zM2=e+)c9??MigtDVpWp>;61_>|CxT}$8j zs)#52YWcO6r?QM45>o|k<3p&B&Wfn6qmd<~c0!JJCc;?dwoA{*7WeDqwPii=Km^zF z{vFBwm*79DVZx5J4}EAcJINfkYTI;Ijh|6AP^-~(sgQa^?ps9;EH;5-vNd#+PtP=` ze+PuLTK@ETLM5wFK2n{_u^9l$K8(j;qzvIcL5oB4)aJ5FuI@=l!kM8`<_0i^yN;qv z$D8J>WA@M?N)zsdS&21uhTw7qm3!VJE}Qq|2;H-`X00V4x~x=yesQ72a9Z;6ev+>( z5N1*ZX6r_}XTfuoOZ|9{R3n`z7TvHtO_Wzb^vh%f$qT0AiYTj{a;(kw zn7B{Oi-KU1uoP&le@AY0T0Hg-}t4-x>Oe%k7v>wSS5hpMHZQ7z@HO;L64glhm~>X zTI^g;*-Rb;X?q$IkF6eF5e3Q)^iwPAo^X?j@%#uFWM5%)SZ;r zOKa>XMU#LZEC91$Fe3G98B&yXf8hxCwKi+a41LD`Xh<_gBm3L#m_N-dwZgE#abC%I0+t z&9URmi4*0-rsp(>IXf6T*JjvQ2_X&|bRoSD)_@(+8eBmz*3Cp{ zML$GUT(tRxV@2i9t5j0u}@bd!_YfJ zt@14`A$6iv)y>VZ%l0h`Cb;SCHDn|nJ(S5BJibY%AsjA!>QHYo9o^_sM^_y>TL2#s zrX_YQ5a-%UtdKs@xn}|Wlqn(bUCxN|JxmPHNhlis+e~*CP9yL9e}oNlx110y2o@>4> z@qPHUhdlIGu{Hqnf8cBQy0e=E`zMIzALxnT-vh|s;8K1zXKd2&VR|S=OQKc~g6mGa zYzp3UquS!5MzCPZp~Z~l@O?KbYSu8}jXzKg8)Zt(=G9(w$b`7nnmsY_{xf6=39gVR z6R#*CUF>u@=Y$`%zpX5eP`Lu?=j#+Ox%>p`;tq9kf91#1zu#itg^DL|&^Rgf z$eQ2btbmZ0aTCierNKz)i617G*FF0cKm(oV-)F@kw(q)E0xN>e9RWMP?^4QtVl=xo z9V*;gV`EtEXH3qK*XmhRhfdNt-h zhD*m8T{y|9M$h_`P(mubxt5GMI5Oeo)BigPfD#J9^ZR-8W>Scu*yw~5L=RK|*E z2>i^FY!u{?=cHe+EEIasRQD=dDacUAorOp$RL~uMb!Jr#LJ`c;u2m3wwr2>8Vl`Aq zDc{YMLdUFDA=@&1T{fTO&LUxwGyUG|e<|aoI+tbZrAj9GZ8bT@`z?abCY`9(ISPWP zQ)C6kR67htw0dMFhFg7%qbzox22o*ck5>l#i69uu_%A$s*dFK+Aa#MsQQG^sN^ykh zwwMZA(a1A78<7=T@5Ip7!+5Q70gT~EVa!C!vD6v;GKOH-S#I1F2mW>BPD$ZSe@d_s z0wXvMr4#h4(k^V3dcE@mDu+Gk5>2EDu+G3b z3>th&1M5sL*NXg4uKGCGjWB5T>rxWTu&ov=MGwz#h&*44=YUF0OSL-`{YWJ&2hNs< zZ;#=MW=xc1aU)7KvU%q1guBo-e?p*OTEvk23AFYNzOlyQ!gQ{>9h>4}pt4#W5lkxy54!)_!i`RpHqLiSLc2;*XgI@Smj}LBb4BDc;6^ zX6W;7bU^w}YAR?ZAqE$>?Hs42-e@%&hQ-O@C{K=+Y!t=P+X~V4+#>HIf3=e;1Nsd} zzDn#+i>u;IncmJ=dP?MbTRTt-N@+7i|J9nSXb6gmGgkwmJPQ_f<@+e-4`fZjQHyKgIVtXhbQfQVj}a3f&p!N7aip!WS(VCyyobDs4tvuZBZ5MnfK zntYV{17hW=llmtrCmK#&zbtLk*w2+QETk(`Kwb?R&x0N%52?T7=& z%cI-ksaaF;`pu(S`2Srs5M%>)&DRMmM0l!qDGRV^qZpsGD-RqXf9o<77am78ns;*) z^1zZHkj)P7?^5i^gm5d&*`t^~6r^U7>gk!|v=)$Gp4Lr34+hklt$m0A_cf?HbNFcf zqu2Kbfk%|)hxfy|ReJnT8~;dIRDwRI`185^EMPJld)#WIi;+nvdeQfKs|o^NYP5vewht1j+fJM<4l7(356+HZn!5d+JgO9m#tge?fA0M&9L)*#cb!wZ!JJ zcS{u3>5DFN%}dL1GerFc(ST(X`qbI4~HIOIgShy=ehf;b2e~k86ugI zfj`@|${9M6_&V!Dg6uKM^9nr6+rFRb@YP5isSpNiGa|GAm8W8d4DuH|;L?e)Rp3oI z;YKlN{)WTfe~74JVN~H8Rs+)bYKsl5d#3wNM(6vF?(DI>?$JR7?e+x953=Q7wwTUA z{t@W+%Y#`Wy0UUe;ikJ)BkEjxLZZ(7E`%^f5Ew*$PWv`KtbZ_rXXObo7pVcBv^| zj%b^ee_F5N5r>lg8oCx0CyEtQqb0W120CFJ6Dc(5+U)}=a!jrux^L``T(xOD4!8Pp zp!2PD$D?bhUPg|AF2)r7$U48}a9YL4Qe`HYsv+vlmgphrXYy&6MuR>KWMDOdh}dW>*4CMl-v^71(81kBPT%~2lC$R63+5~R_hW;7Rm87CgPD-Xf0RLG7;WKqq&&p zjTi7R%S#RZ=wfBF){FdalqOG~n6?{V$|?(l=x=G{7XRk$%YetWcsMQVYe=jyOGIXA6IeY$Wb5p?k0l^+5tGKM{38aYJ zHl(;|*ZR@>>FFBXf8))3 z%H-1=aKug=b*jdAUtFNXttV7T8EE;}k;brc^*mZLi7!@s;2rPGFh@ix4P$n^ucEqS z;ut6n&A=hN_Y9>CC!=%Qd>B&Tm(}R4uUd4 z&9A->0B(JM0QOR9$51F=UqpzixLAR^GmNr!#2R}Ggnu*x<2*+?)30Q~m!v~G^=XNm zR0Xgws^+bH0fLk^X|vo-s+O=H2uVr`lp^lgHnGM?a0vWgO+9!h>NP4}f8PcCu*o+vCgOa$8>S+`J?d3BxTJNN2(KS^D zB=?bZGBm!|y5EAZiEUaH$}XZcPot{vWByGr_J= zo+r}-S_cq@ebyVdb58^zh7se?}_DkmDUhOM`VR zw8lw#v_dWQ&uVDbrv3Go^FF5cWO|!t;;n#4Vr`&Nf5QJR5DVVpgK}ViNECx)zW7jc zjaOWP*EvU^Q%j#2}S1r30DExT@;=K@A=4& zTs0YqXdM@v12!?wf5GnRCco|q-&=R9s(oCFj({el{0c29Jy>)kWBFGTj~o^X_QReu5+V(pXC;JU9C{7Hk_*cqbec6U~ne zO@l)W?S&XW*c8#Hr())v3KY!*aMF$3Ij18+fdJ2rMT!nqe|Rhcj1Za=q+M+9Zr@{1 z=7*sqb1p@;Af8h4ToL0N2}d}c-*DFollb@sY#zG}ZFKg&QH>I9MofOi1d|20@EQ#U zx76v<6e+3j0)u_hpJR^E=`^FbagdHga^prEv~g=-B*~5Tb&6bFHfYjH@x5NAfYf9m zlc5kJ!U=$Ef4|)m3W>y0^Nm#L!Fj;z@P^ca9OY-N6%GcO7Jy=LPa^%N%Poh1f_g!*sjZzHC49`mv+>W zOgBKte=9}|O>vVz&SJ`n&4V!^?uc0FC^Bs!&e6ZR+OWyCH^n=6R(Aaaf@g@5lR!D6uY03;E`N?&jIK^%p_o&zVy4uR97z ziu}8WPt*h<>U%@&E$q7*qz5LTZH&^Ie+>6`H%3F4d}4BO&c#58zq4Vya&wZ`Pg(2_ zC)XJ|3|PDv{jX|HLP9J#mfes6<#l`f_b8T}J^jXlZgBE#5FgOP6owhz}h-K!=R#8wYJYf7sNe{5a!13Qy+}iP00!LxJe_OgnZb$TUDJjC(Rrxe=7A^ z?O2i@0cF-4Lzs6VzOOOX+7$g9t zK|_k_3qYEymKr)$Y%e<-Gl@O4IovlASB?al!?I#nWs9sx)FK^+tSiL>-GDpU#%2z@ z4;eM#c#9AC4kL3>)eY(maNyc-f1~Uu6iF%Vj>1L22BAo%K6M%UZEk?FpJVjy(BgK} z;b2h||B-DBS?s-npUX|pHdbL4jyGDXUyh2W?{b2HOi622 zE~sLF;Uns==!s1!h@jIh2=Y9{)Fd#&k}^25#6=J)uSRW3mC#*CQ)M z8)DcjH9Te!iH2g2&MhG!eS0Ge{$#?3ejO6AUXO*;g^3yXVI{6nstk+0+$SFzjOaC1U1O3kF_=|4$Ym24k=4T$kzh z>8|&%Jv6Tql_6)-r|P%w2CSuZI1pDgRca$Sz(={pa5jHh*w}>NicgZS-|`4r)Qvnv ztN2V<>rcmBf7rnxFz!M`@>rO-%iyDJ5*9{?m=9Z;_VeTuGXfS(Txq#0AC6+gYi~^G zj!n^%<=*oUjAMb^TS?%7#=VyYNj{;kuEK|myuMaEPK`l%q@>Ji-Zs&?SNv^YvCH z+0-ltpNkU-f@NK>H)m@UK!-Du1g)rm6i+zb`*2-qV#G!0Czu3CX@9u+gg3Z*6egWU z*b-O^f9#=7aBAS?4gaHom6!jJ8Tjg`q(|W1@JpZrhCcm|?Ep_w{GAm4X8wu~PkiuV zzX;@{8pW~;ZVN-m?(D)=3_yrKI9-^@b=fHaENYN$athK_0nkkfvILaBFVGL-R?ri`` z^k4M`=nE6IOdlyNMLCB(j)MC98?k}muFW-8hv{-X?Y4^qz8k=P7jL4#Ar9mJX5*$&IxMobwf>YWk_%;1X3J+-U ze~}FCrSJp?QAjMn)ErSU6*$i7R(7%!e$P+Rh)ve~JhWVo3fWBjToNbJ7XH5ZzkVczp5d28T20 z6wQ^^h^*Atptm!-wM3f`iv`rnsR+DI1`iN%3{|~-D<)dNnUg<0A6`JEuaBJPnCXNM zaWPQY4`6>fzf}FeT}-E`iXKv=Sri6g_=9D?voQyl>58xpeJd{eJz)4Llz{Z{f5^1o zu~sTAy2$u6V7+q+Dp0J}(vv0JMRVf_9xZD6^;^%@i$5|xK{-v>H1m^iEm*uZq&+*A zLeLzeN<0g#k8>Ik%r1R%z4%DnRB~BiU7sIg&b}oy%^#9{`dAe zCAw=CUHVc3RsNt;YVC|!L+MWXe_=w;!{^MoGPI#gd;)9t#=QB^?|ivBG|6mxQ_mwa zsdEi-Q4z)@VrH^9tW45XsZmrcSy2h~dH&OGwf$G>GH{4P@c58igEUrRXhJY^uo16w zV8SPFc7#aM$uwf@?atQhLvKeuWgsV~IXhZo?^XYyeF2L*1ost_-%ScGe}XP1BjyIE zF0&x2OFYCO&BZ4%gW!XrY7%htE9+srD=a_*As9+BnioCv+`0>w82kJ_CH}Wmo&xa9 zzoqMRcpgBK{V0Y2kY<@SX3&H_5N@pgt9cvsbNB?~K#58QVjeW1y&CC*=An+|+{-a& zgZZo(*>+#A@SdN_9R?)8e`iW|%HOLv{!xkvV_)c>uMOY3l zFkj#e5@i$@0{6b#e=NFyp5a~76Axo@2<0H~*}yo2EDndz*`z=aqq`f3(fI|xRKh^4 zSLhQHB~8wJn4LM$yXuE2A0_;#CzXU+O~XZ+#`~YvA%~dw{vT<;VkS!`cDB#x={mA3 z1hsy{KThdxt|w$L?4{RiFCCCTgU1NmaGkV?YJdGU`K5WgfA9!sORA)R+8_ngN0c@5!n!s4!7x4FZW!-(DL$YY`7)n?2|vCpQj+7|3L=n)qKNpd^KS zl^i4-Ia4JYe|F4xSA%I0xh@t+7y{!wz*D}FdQ%`##jI@tHQ>@O1dM*zQ_$G`T1A$BmfeKW!e4c?=Q7A;3D*g0ED z03?3eFc8hYhetT&FdnO%l~IhSj{iDm@mvA%aT$``e+3`BZe(=U%2uxLPo?chwh(6g z2dx#PaF{d^kkMiV@;%7h$}ujllM@76c}~meFx%xFu=H*FHs#BK@CVE7W$W4RX~rRL zD}e6mK_)z>`756tP}IRXZ(WsE$b8+;fKK(?00+Y+Vxe~hT;(ry2nYkZ=(U@VjZ95x z#leg&f4=n#Vltf;+S4#`vE9d%*+*AtKWD`pVYF}w{;j0G^HD>ej*+Fbq0*`dIFt>Po*Hvqy z3c57_JNg%4;-yFL#>ZJr$KTgRbnSHmPPe)Qf9uTsDjqEC+U?oD7i>1@ugyp9+zExh)HED5T!(qq$8* z8etX`x20*bRm#n1GQG)Fpq0f!3&L~FT1g>K?yNaL9>>0L@p=SUrlQQ6RYZD_dwY$z ze-K^_xFBudH3m5(XQMWgp(XMB?%74!%I`Gk$u3c3ZLhv$3T|aao?=JFcc9*ox+Y)C z$}a8oOT12$EEkcGxRhx=^zd)3Ni-EI(NL072A%kLQRv*iqUbYgw*xCaG?{EeSJ%fq|8xdX3vVEo&86^=+` zCM{%4pt6n@?+!G$(t84cbkLi|WP3l~{$u8}nO1eugI2|}Go}&;Ofa_*%-OEnKBlqB zedgElkyPfgs&2u@8`pc~obeJmfA;t5NJZQQY#J~6X6qw+=4+z}^7s3Osbh#8Z{P2+ zDK3sy7zO9$Mb2{}r4FiyQ3pgPk2t)AfCuvGEhy99^*gzUfTGS9<%5@S=c&jQ{f}LX zU1ubp<{V$KCVVP#HEP(szgtvv`UFctOWFUw%Op{0h+v0 z#h5<{GLZ;iNjZ+Jto(};P<=chv&fg}l?nq5ZN2jbrH@C4XQu2nR*4?Dj07qmwQd^E zMfjo<1U6Bb!N)9`MV$r!e;@)GF8BYG3^bU|q_>V8s0^Ky%rXBl!Ifp3A}S=LA!q<~PVu z1WAiJL}&rRNdKy~dTzWszL2#S3j27~QX0AXW zZcp-7kMc+e=Hxinf4XlHy(#KU8XX4kffo5?Ytae#gDMA0%_%vgbzMQ{w^;f;lMrL! z@#v>uZ_f=b1<01C@kw!G-OOXnk6(S=c5fkEpju*IZ))N0v*_^+Jj%t|pckZh(=M!% zE|@ThhB#7^7s)?~IE3qDz%=)=5u8u`E{NKE916!|?9&Cee<>03XO2|ISq(Sa-}pcv z@V21?RwFLf0(N+I;vU0sEB2LL5uxoT_&+K*7mnD1M5oBbhtICP?1K(E4HMsVKp(l7 zvW6AHJvwoi8ZIZQYSZ?>&3oslIVwl`lrmh4R*{|%V~C(a`iwGZ=ZXHzB?!lq80^$2 zckF{`YPh~=f9F2Zj?gON%QCgvzJfXLZ0uDTsSb#2cSR$1_lC*Njs+|By zUf3K+yo78re4Eo02nJLpz!GCu=~p^b(FYQ6!iBKAe_=95v!a;DQy3&aY0S@UST~Zd z_#Pes&2TZnq(wlaXZel_?$@^p@=EQ3#K2;q#DP1~?5ja{h!=Q+tdA4?S1R;;zYpMUO4jJ7wxjv4Z4o*(%J z`{sjZe>6qvQeTtf-2}QmN8SidV9Be`$%k3dio3+!itmIqj5Lou>{C5}CEyW&5pF1v z_>CZzm~L+G9z!+$%J2H!gCmg18bjaH_Y(k9@mFJ?G5+lD}k8 zy^RiTk)q|+ptV(Tw3uGAAeBYwa6RZ5qMEc!NAoT`wu^qz=?;o2YbD~8yb0xR?ZjMCe; z47S-0ntlRkZ2Qif^@ZlfSH?DHG}oLti-+Q%fb0=oRQI8#EAn7j0OFccqEAK;_ zS#T!}(Sr)Qds0QdRUghA-e0Uk(xq)ujqb}3XC45baWELC+ysTxTkAaNI8k{En{|%^ zN}3I}UGw8`3uFWX8pIWO2kVV)U0EjVe~;~!X`J_(K0XzV(t3S4LYoR-iq3i;*(FuI z4~y-p6hRn78%Ev6m8s>OBgVnHR3ivuAhfNA5m>pNQjdfkq6IF0`!r$q7|6Gp@fxXA zk1Eo%68QS)(+iG^*%(fRuR&)Df0D%7#S2gI_;$pLd_Zi(l+4twIqC5dfdx2J80YNN zQ5-g4v%F>+NtZZeX^+t#Nf5f9h&+8u4+~`ij+Us5A(_Iyo|1Y=XU31^0Ng*4`~qXM zbJXWq>vko9DBgGou+p7)ha0{1Yz_l}>jR!|k0xk)+T7bk{!6o36nrZ=e^3o&WfQ)r zAP)SaRc9?obmKCYN`L5*Ant_@03l&v=qQs`%Y-38NX0f|_|pa7^%F~eLsY+n4l|vOoGhBnMycn~)8}Tel>sVbh*?24 z?Rnv*Y(6?yM@kt2*CFJZ7uDuVvKhUP;Uk=8e!uh@9qDBYr&IH}lb(D9HL92sgnQ$B z>F_~Sn6rQk3rvFXe`!QOu4*#4U>K8=#LgpL%jq`Q7AI0T9&GVfAD)e|Y!TI|Qv@$A;i2_y#I| za6(5#xC##<7~tbe88SG?hT~Sme9!q(>>F~%NzaAt2RC$?=mXa#DENCU~^A{1bt42~%85KBGIN+Pj9n%r$vU?D^?Z=s%h zjY~TP6CVCQ`eX(kJ)uSo3%4!0Xd4Xl{YSp z77QfWe;Jpj45Cys@f{ihX|{k*Rjz~@6&`ptPETo+sx4c@1dH%_{o<#a`<5pm}((ocA$( z5wO3j9#1E!-pS&hP97&Rq#G;}j-m$Q5u!Vsf4jF{*#-0q6iIzdsgcqIGpY#CaUmrf z?i~T_Ot;ENsiF+yb_tm76*r>kZXv#VTtpjl{r#M73N#Ep0|x~w&tfR4R6s5&TExb% zt}Z7h->*Vhvm;`07(Jz2=Zq8-&+oXftz6$@k!jzlM_QIZrPLxw^2>55Ck4po5(f=E zf4q4$bct;WG@p`3I*$37;&Vgpo+{GF&Eb6;>W+m~Zik7+pe#+|y+OBwt{~*l{={8E zdIR*MvB15$M}Z~0q&h18Vf+Kb`YTc70tPFTBO5;#I1F_hmb*X!#UzD&8q{2CCIX;g zH+jGKMzq|N<$EU&0~_K4(0BrGoPdYLe{7Ct8AFi4oo@rr;VPJvFSzq@-oxht^o>0{ z${PU8YIr40?o5U_{G3YKkA<^bX^1`jit%x3NJb6h73{y{kxf zWNpN&F$N=1H9RpyE%jRrTwmb{c(BsEPnnt6GtMU#DPaTQckbIm1b986^gKYlf9*C9 zZ>ch5ab1#*b^&%YAW)d8sG7B^?ZmmlmaDP8aocTza!+!>W!O<>(^^P%_i!4kZR65N zj|}|Sn%BhhbtMeV;Pa`npx9LZT~l226WskO40S)``mSXY{CrTg%v1fc@DR3(H^ivS z?bgGeae?vDuu9jLJqWkn*Gb)~f0)j9^%(%wMt!U#69SSp*gujkB~vjStbkcus^J>! zh!H0pE{x{&%|Qp4d`Qjx{DU*Tg02ynU^hUHc0wL)Cb|p|hx2XezfdPEq5gwnPQo{F zKBdE-_bT@fq4#Lon<$+-uG}RVk)CNgHT(+%Xj~)Qm)kSBJB>IM+co@=e|Y^bUYKD{ zO!fOJr#4c^48`=c7_4mA|FVcg9%GMl#B4^BcmiCdU(uI*N$_3+jm9F0#=oQ9vZ|z?M9I> z14YLF>_3g7qq1VKGO$11GQf0wbk(qH>M#@`e} z3a#}9=i${s6L^UT4qsAgKmWwz7#Xp6!`r69@xeAA2m!tZ_pTIo_$!BDRVn1+@Z?F7 zLe$O?gyjg=9XzcO$-!q&i9kuv()}2%*Mx}RudaWaIBHeNT~Ugb%2^smt&mdvi7~6x zb1^g@B-zey5NJ{ye+KtYw}ZO4T2_DV&{5B!o410JNUK%+*?3O>5}Id9|AtV*>m(f% z_8a8dkO2o^B2SK>@1&n{-H{Sm+C8eDDIK+3Sw+qdN!n!@Lp|yJNj9tr2#8-$QD0S1 z&S8z8KY<~GqaUPKw|lc_tSrA=QLbk+lROU8Tto%n~Owl>wluI|AX@wGiV67Pk5$A!NdX$pDn` zMNz#lBfQi_j#HYgSI2hn&P(ptnTX&R5D6)m{xrH*W)fs#Nwjt|p3|qI6^7T?=*O)I zRHooTn~$slf3^sZ*jmgbjwDyKv!dhJXwsp(1>%tX0kM>XBN?3h1p`k{i6b8vuzAjGtOfP9P_uB=Xdkmt3~O5wORYTcZS9ks zgr*US9_~IrF_5mcaAo)8Fyg5B{u6$Bnr-JXxhYose^nLIPT)|0Wr^2jhB!Itl!Y$R zUue^NF3mSFj0Z}q&p4NfHi38 zbRX8f9@1h-6cTuzw5G&Hp+WD7MY)>XnNDdgsRx@<5>_&9RHE5=4le@wd>I8Q?+isHEG)`+XxH6Eb%kM%$; z#h;7m9K$n4qkMf&f$#Le2r`Wp2!P5c(?y?XBFHNH|0F~axG&q~`~*1Pw}g8N0;@a} zP#AV-fP9Kg0)8o=S0GFdH+E&5>++pW&x|ZF0LSmFrql@MHihw8qat4_p8k9|?u)>H ze~syRm-}gKJ{zd7@CNZT5I{fM>wo(DD9#2Qv#X9T*-mjqdtZW4kA;-Fbo{6!t*pRL zT3Pi}$qWznNly>gkZ!GbJrY4~bj)4yVA zAc%nw4#;~1KmdaQfQ12o0|Ed5je!FK0|E&GP6h}ALV^HeI1ow)hcH@z5;Spyy}0L_ z#?WXxi;Z+vG`-Rf2=rF_zQtA9QNMiyKw`O_d zedx4~Armwhydc8`10pW;sfKyhFifeY1DL6y-zPt+(E>F~RBnXvf;AyZ%DOBeK5}Ru z4*Kg+re!IHimaP*q4kCw(SkrOf7*T5r+_H4nXFXkRY0D5i^kNWuO|W2&BOsMs+^pJ z;?aNt#-G`Uql3vAilLDeK4#(cG=>Kv%o15wEeq5av97o-T*FNc>!oReKZ(zR-vW_j zhdL%@XN`w+GHvWJ?c<2)YyzF~BwvcY*guUZ0phx@0uw;eta;K#9xtd6e-T)eRsa;MdAT zdF4q}Py9r<8s;|fyD?1|Z$da2GgNd_$`Cz78P;-GvuM zo@{AeA_ed|4hAgRnSFGaF0KMB+SIX_75GQF0mb2zk(z;WmJkL7vU`A@z2g7Yb;wA% z>yZbNNV@_d#O4{wbM#&t={umPi;OsNNU`-jzWs>;i6GRI4SaPF^nIcOm5kH_#hi+& z&61P}Ufle~*?M6Cf4+3aB9MAe2XhCJTc8R=ElkV`+L5im10{ zxw^PCkL~f!{@IAQ62OTE_SX2KC^bzumzlM;sd_X7i&mgvf5CScVk9uIlmPhGKjp&5~?P8Crg}JQ+px3}THh6_` zT5y-ml)8~?e-vVJVS~{)ss1FH!jw!LHWN~T7_38yMDEzYWN^rtz&TQ;fJDBW#HEeO zl>~zqK&P`pm+B#sNgNIWQ7;tzkRk$i5MQY`<_1}#!Ym}wwb*p1D3R#l6$d_sxsi}s znc4($5@KzJcmviNsd^|Fo{ut>vEV^+3E%fpg+ymUe+!isFBFyQetNmC>Km9J)#}g5 zxgQbf^1Q+n`7cxbli>clqV4UBQT*;h(ApS{SMEyY?Oy7 zXo>%Ih=Zb5vsd{Ma(NcGya&4g9mlwjNj6?T zh9!~#93px^ryKY;mhiLCeZKk#;Z?u)sj+}ve-QTeNcSN@9;Cv0;BRgVtVXoc9T+vK~oj8k*s23WhUChl!UTSbODsk>G%FAS87eFA>yY8B@XI^|6YF8d_o!*w9 zf8F>0bNH({)sh^~OE*|Iyv{x%arykLCDE<;{XtNgS;-aL-EN>zZrgbu&{U|X`lHmM zb(MYKYnEll&};3HMhEqwx%fq9DHbWB;mp?4kQVu!5zTtTXMXlB+tsDxVhf5-LqOHw z>3h)f4pTiXP-w7Lx_>?Rn+cI^3#_Q_e_RR%;1NkbFDVCK1rm~&3X$y$5g2;3sY=U* zMTo#s^&nMc1fHj)bevi#MQ9z70B9>IgmlC&5s}&sgO7$3i?I9{R;?^-;vpsk5YNE0 zFU-Uy4gxsvI&@pxzAhR;7NCANq*;j_xrmGih}U}p5lY;MMhX%sDRQV%T_Kzme@SGB zSjS8Ubo^)r5GA$FHYk3U+E9<4Fe}K+d+!_?!P6q%yiq^!$fZ$kctSIWQsM&{2a3aI zF+-O_AqRE*waM0c@1}Q|A;u^DAT$6wFpvXL9y!aw7^r1oe(m##59&TWZq&p zow|0Lahr+UP!Y!Tk(nGEqG)>Wf9X`?0gW2O03kr$zb}TN%*w~40F;>tT0m9CDW>!b zm@gS;EyhS3AW5dZpL^v^=ORxkm*339fD+o>45aV+XS7JhgxpgFnU=gkX5u=tVIvZ6 z9UdvCs0mGq5FzETfe42tuYvJ1t(%R|;1DE6TVa)+YB%tXdDfLdlk8!rVU8i$ntvG# z=r&>3ep?sXb1E0pTLvez7-kCyy=`Jai9Z1R0jbg$e7Ld&Ur3_iseNo2!<5|hRa4A& zMRo*RNTr|@g@!3X@x3|}%C8L!wT$m<8_6L^ykFpjxgxu?t-UBzq#tUE8{hs3c}1dr zAUtviF#!HbFy@sdSQ)Au*&(|nRDS@YGy$r%M9z);A8ErWRI?{$mCUG z;KKi!jJ)$IZ5Ol^KTmYn$)xhB_p?F25-xdCz`_4MF~jSIykoC4d2#))tr&p?Eu zZDS(Y6_~!}pBgF)@fTzZx_e*_MNh6|ofn)WFv$yWt-}_ZJ;g#NlY~?OWPfHb{cn4x zeVi3&1dB?QE*u0aAOtRvfeWQ}gs^*pVsZrlU=!hfxn>A6)CyZ=051u}l>m7Q4oRoU zfV{?Y$x5Q05*JG{KTkC!Wl z46s?}9mv200Rh>x!?n_RQluQ9Yk8p76azP;+Gk1MOtkBKF z$$VvONnBqktOt7_E3AxU*_Q{ke|H4hs_#|>Y1xkhgL2CA!~{vo=6@lWJeS0xtOi*@ zA!<#ZiJ?lubh!hD{OeTVr0P6^mZ{RU>Q-21~&@S3#{8FQO>&562KjKT8z? z@!C!h`dkc&!OFCe!ie-i%s^@_t@$eH54Y|G_G9NWL{UlTX|zLYMn6X=3D|a*rzBCG zGD2p?jB}DIj$$JH?fro@pzvnYLM*w10&qDqkkyCU*CyvnXLM%(?^vE z0t=5=DC|VY5R|4I4J0K>H9`PWG+?g=tPj&v(n%Dy?qUpkBDg3_Cfj9EGlOwrxBp|2 zfEsM;6Ck0ClA>kQ$geN5HH#WyhV_490M!F+gizEBo8t3<0@f0e78myh`z;kCm+Rg7xe!zbQ zWI($BRN!Zyf78MDWX#Y1j2~>b-5DK3MkHLt*+NadMVXTX6@Y9WxMECOWYYCY-7n(c zh^~?c*CGhS2S*P4ri3;N0%2_#2T`4IupmR2Xz1>&X@5MtbF{?4pCJ&eQs)FkP%+j* z1**gmlIa^9A9lV~a5H%AE6Oj~!((UBL^CQ7phLCdeza-F#M0or%D(?q1mW4TV6}aaovZN}&&tgb$$Roc*HPnwj zq!|26F@F*o&Y%?8MG22J-SO7TLqW~F@1wH{$dDJZRNp@2a2NqFHj+ES!hl?kOy72u zN?HGJv-sWuBL?7{Nk{srkzpxm{lpS$mns6}6)15SxHOV+p#5SgISb$sy&&=m!ck zP5S+=nczQB^=l?0OrC-nEQv<=8A5s#nSTpNQ!V_==!ggRj0wq)8G{HaE0Al3a|gqa z@+CjQqV_4zrlZOAO$mIl!`KK@`;dLrCc-i|XherUXlQoA%afwc=+f%-)s{!n!X_>4 zw>#9DZ*K_+orWrU#2Da^gh>D=28d@q2fM3(H(Xso*%F~;qvv@TYUzH1%z1UO{T@j_VHlFK28e6JFm2H?@0}( z3k6Q1Nlg$_p2SyEY~T`-w4EpPCXhzGImyuBfq7|x_*3wyL&63t(|leDT>ZbYNT`GB zb>z!qpDzvNhSd=P);wyEJ}?6@L4UOw39^oS5I~2N%nL(&M*w$xU2g~optDcR_esQ$ z4j;_XHdHK>v<1xsa2XKENN?lEfW5IUaXy9Po~v`FZq-`6j5dKO$? z_3-+zz?lDR_ssRZA_gqNo7B8OUW$i8_eUb`9tdyrFrlA}Ki-;F9<}>uiU{$uhXM4uR7ow~*5)T5a;+&>3rY@J4?emJ-w)M_eeC26 z%%r?S5s2k}_W0ERViguI0;6Y-B%@hm;Koh{0jne<5D2A{4cgpJk4>yzd zhA*gmv>A7?D(m2OI|GISFgU+oEjgf5^?t3&4J9M>mx1I9ZBFpWLA#B;=L-fSAU6(56hm#NgG#7`swE=c*DQdKqyYjezw`swv)j z_TQiY{N*t{AwlXSl7F!l_ERlX>U*vcK&Vax=bY%u4vdT;z$$NTV{~>sIF@otSpD4P zut6)`^)f@e=6$1%nHKNggDn!MF8FBn8Wy48_5U`3TGDD*80IB5l+TV$Ft7^Xh`(~SmKq1>cS>^%#eSukQWk7ynv7Cb z4SuEFsCpF5&qvWFUio8kG6TzoU;)Yw(jD8s4&r@=<-yr?6p_zbvB}~$KkF#^jZCvb zg0MOqivZ$ElTG@Ne_#iODX0^FG|93VQy?G+K#=fI*>EtANP`p+k{=^b&|ok`3mRK^^m&lc!msGY$+9ToMNS_y+ z*uItCjRK{BM7P=JZiA;k(y)Q5Iz!XpqtBtcG0%*>(uIAJ0ukRZREK=f~ua7{4@FI5RMiL2{o$=5uq7Z3Yg z0#%L8OYEU$qXdFkJF$l6Y@6!?bH7U%U3^>l(3rtse{p-0jhC9U_~~>NYW+N&6VFo7 zu;|2|>MKhT45C(@E8=Wo$nDW3xXxFtMMCRb-5OhCmno)D=UCou5@L0?!w#~LZw3ou zxs$JokPQ|_&K10VbtY*70KqV+8u_NxsoRlWpw@)+nD!cnI5=i;{OKfDn-$+Gd=oco zaL@JHf1gOZRtrB%(?2G`Bch*~P|OlHq);ieMMNz2_J-crd!Y1+s)Da*X;ii?W*`kYn(d$Q4u8){%G>X*^jP@e^t*%e~w_1AGPxT8uU@xagD(3Hm%8{ zVBT@&@!u)l!ZU+H`^jC$Pxr?gxqlpe#$C#NficVykYIHHwMLS&v|z_A7V)MO_u`b& zys-czK%nIhX1ZVYc^N_^m`@-3OnzxiRrP~<>vPU;j%=^8hN|lSd8h~}FnLoN6PH~=1?d=So zcct}7w`oe~k_%9Ans+aY6kZQlDOaSkf2P*=ni@U?@#6+%=xwV0{!e;tPA|Hk@wBSy zlZzCc>II>v+lJ`rk@`Fv>XWs2PJ0W(Z4lH>l*JOxX;=*4h6`lPi)}a^^;NCvrZMT0 zNB_m?q%G6#n~wY%a1H{JX-!EG@9kfbQu5mu`yNe>S@c zOr(pU_$_l*XchwLrTFv_j8FZ3`^q*xB?Yu{#D@|UOqKVGOr5#KrpCy+W5== za*nM8g7C9qH3$V(%&RhF)Ef+a)f1Jnsv$?H$XC?`cB}dvOVF$T2u{{|*=sDM7+|Hy zg7B;Q^8vV5!T+RRws0pIAFP0{oR~d}R{BoM4<=W5;4I+10kG5Webk=zf3KzsrKrxb zaK|hkS1puM>t`B1!jZ~#gAb;GC=;Gu|bz+e)bC+Blwr$qrtTw9cn&gaN z&`Njf!_ejbu@W1yN3dL|f=vkjV;wLcYvOQ3U38098MwKzR`*-JWv<{i3i69*Th8jm+NCd)!CmM}+qF#eR)Y4yf9n;QRWoC=YWpWZmC%F+(;tqL zhIm}2+WbIfAO`|^(9&d7u4lvk&j$%Z`LvQ15BbGTVRa72 z982#Oc$CZOp$H-?f10HuSL0YiJ&rX?Af}!zki+mh_j8tXzF80cWR+0IQulH^76Fe{h>fVTv&}L3cl>FemY- zP<>)DH7FX>NWAM_XF$TaIG^GfIerne+Nd8T9n1U{CDBAx-G1-MVhVYhSl$=ICV!IV zl(G7VGpXeJuHAwU#U|F-h@Z`BKW9-^w)vgMFxXv|7$h9CEO!#hN(#e^t>52_|9Oe`y{R-?LEUEyl^(?Nx>qU6IEm){%gcNj$k*!xv$%0khKh zA_GvyKsdSAGDHU>zQ%a>@)j^;msEo9lOJFuxS-2TeG31~lno=81EM;}2!=R8x`Vh_ zRvE*>hdYQixjA8$=h|?Ks3sUSpxb`mDeLd+jsL#1f9^JbUz1U@H4GunY=vVB?|Sgj z{GQ3+s`G)r+9>fl7kR{*DTmu6;Kft|enOXk$;bo@_k%}Gz!jj4S9q+zRz%KGbHB&E zn0Qt~?hU$w&ttFH#F$95OtgmC7szD!#`X+L(!+Lh3r06}jeV(BcZTWkpE4aLYg@A4 zeS59_fBijeiHt%NKoWf1(hzm|mIE|#vPtJ&Z71ouH=@>u3VUTtv|{${Eq*V%zrBd% z{{G6|g6!yp`3!112x}b zk}pmeV>vq>>vqAqv}Yct$`RcHJt?aZbARCCe_$gP7ezfjC6$N)^!%QHN ze{NW>042m-6QTyvl1#KsPa)6xG982_w4hXJS!9}*=ic4k5_u(EdwI;N&%w9aP|7ErC+1XfYOrwh6x zb9h!1TGAd@{di%GnMkdmSkTo>r(l)he~84XpJLv$b-)9$seGqD*!6Mep;R6Os`!vI zO5~h<&(y4A`us@ld9sRCIW(p9w^MoWmfS`@0bN8Cdm^#JQOf3d74MHCW@po3l*7X~ z+3_gPUBCn$tzc@Oh8k!UeAohX2v^5aze2R z-7ovv@YO5$kYdZE3M!}RYcOFAZt2055n2<5}8ffTh@|TMNUUjXOaue?Tv6ZlV0H zW9ETSc-f+69X*>oF>tB6Jtwr>t;_SgvY)o5D>!}w8;IKs8gX(FaB(rV{gX)d##hU@nctxi}ExLz4LCB z3TEuUXPRSkIo_C*kQijHf0$v?HA2HgA5z2Q4q5+n4U?$bHcTx0y=L2lEigixhN6Z7 zjN$hf1Jjz%Nh6w;p_TB3-YQ5Ds1M{2h234y#j@Gktd8SCIDK-Ba4P4&)ud0$%3C>< zs|4_6mb?D%-mJobuSoU15;+7n@TR~{$)`z`BPwf6 zF*HPA+V_mx(2df8}gNtk)_GIyQ#yWecV! z!abdE5}ZcuI=CZ@GR1h9CG4V6WT!r;ycv2O}==S~UuljcdpTP$WWsg4OM_$QkmLcV8)M8VHAR!`gdWfj;!e>ft*g07ylfd{o`>$`s1Tt)T2 zQP^icH5m@8ajv?n{Ti2rY!U%6iYH%nIp$UPo6a0a;x$9w?S5TI=hcGa9AK(G;HXkE~uyk1G*oia!5~fcO zS9_*1f6gua^R1J5XlVt(pxZZp6qsc(GdY5I6q>YhN#iVzuHMcG>+M6~Pegem;>;!t z@UKlxh)lGO!&;Yx+B|D}D#R@kvhx*T#z8OWg7~kQC{wUkg^qIy`RPU>eQ-x1nr!=- z5yMgWl)k`tmCcqcP-Nm4sgJPTYzqaJ$-Vx(f0ltxo>ZP37xrDyGdZfG0*QydO+pH> zB@F>iMuY?+((QDFoc5>D2edj>xP>YpV^0H*8*nwf!T?;vL)!L_RR&p}rf(E8Z+Fk0 ziO@DAt2A*cE=39bkDR*&Hj2!tGc2FuEDE3D5_Tlzx~Rjo3}qawYsR;M(*ElWaZss{ ze~G4^9b3dm;tgmHqPx|%68FUE-0WpNhJEfFA2>^S50szz@U2JG$HjaO8@L3W8e!0QcTg0GZ&^S30ShJHOCfoj>C~cfC`Y?^U~;y)<5?-K3ed z13O2Zzx)5*T6^Ev_BmG$(<~9bPbu|te^r6Cl|Qy)-K30V#hPt2qt%0^!WIHki)`Wn zNpUIUb3{^!N+A!Ysco}}->4M-e~JeE15*G904N|JC@hsrq~T~tmSaBzpuhx!f((NJ zh=GCu6#xZ*0ssR5BLD&bfB^sq0097og8~2t0kL|}GUs;(WJZ@KmUxPuB7fMhKpHZC zzbNvL-{^;25$wHllSG=kNu>GCVb5Kx2OiQE+Q@GGI(W$^$vuDsr&sS~f1nZJ=Zt^} z1+u*U*PXGu;MxXyy-Hn%QzQL7L}s&53y<7b#KP#kB-0U?YvV9E+VMOjY&;LfBvxv`buTo%1kxrTviFaS+ zZei?SCPXvT5@UjojENdrk_mb9Me;>o^l7bc3>R$^@4TE(qN!P-e;TqQg2BWGxkbql zK<4_rqaJ4aX#Chi1EG+GVRsLM=dV`&TI1~{j$!HEDY2u*MMW5%(>To-nV{J!Ft&O zlC)R|1HrWmSX9U$f5+BrW+sR~r+w;}QaJA$2=`sEE>UY&mK<2|x*FwR5|T1tmTn6u zvD)@6@=J#PZPLEL|CzwKt);xizKT$6IdB+tfJVlbE)c*RpDtWq;mNL+Ps=M0zKuypeLmidgALKpp*-8k`bC&3EgE2$hEqZIH(<5?g$=b1nn<;*Q~Up=py+X%Jqk#GW!JG!#4qQ@yjb|~5n6-hCXk<^_Z zz!6gUmjEyb`z2H+gapbdXCM%c4VCh7cYV#irRiuM{b&;e+Zl=qRDA0^a6N16DT?des2|$@>d~J zl?)DvXLjo}Q)Nia6l0>cI;QYIhWS_V*Z{sro8Mz;2mX0BlG6z#cq_PkC&rKPlBZNi zYCYYh{)x$9lu`G3>nVBBEkkr22|te5^gJkfv;MzRrQTV7ay6#IJh~?%XV0Omf__Zx ze~A~ZJ$)s%r%jDSC@wt_rT`|m&it*cPpE6V1)buhuX{^RoriJ`1)B}5JR%UQqpNM2 zrD8BY>4va2y#!Q-jEdrOZL1Kh|3^QZX2Z{uv8>b0v8={RBC5qWgVn<5c_9WK3Po;P zaTo&}p{wnCrp%bnbm=R0$8yC=B*YwOe?QtcO$hH-pG6vs4%At`WuGI16SYxH6)eN|M)&zlwp7kFLI~GuG4-Jla5XgGRYBBZ zzQ56O6ZapVG%@k3g-W=GZ_;rp`Pbcc{VG_5-R*I01*QL&LBcR?p2^iz=!#r4e;F3! zG1%`-w*Y#0s1IX8;?(j>w2-5}+{&BM;2d`c+Auqz5zQfyb`l3Evn-0hb<6N{h*9e< zFEtJ_f(k6Qjv!FHHAi=0>L_rFs|jwM_=wc49N!Wvm^o63P?#v#8iSw?b*xfAYO}?} zn=Nqno#$}2#%zZtkL>UtB5aGoe~4}Ugt4t!AlrhBvMqU1?1qrlczV8SagMNBE$#v! zcUse6jwoypfXW65O-op%+y=Q-O<2n&Y~=$1R5UIzOM>``o9i9tv((zvWwBj3RNK{l zU;H*wnt#`NCCJeMz%=znjxW?TkujFRI>tJ+Ke3u`WOb#7l2jmAxdPAj=StCTtq@A^78C5imjdDpXdy&@C^f2*`7$|5za%7|2(sZuLO4wa(P zmXrBSSB_jjUez{PY>(o)OX}^^CQZ4B4F8oM3Bjm^L= zbHV}PmZ4Eu5_&GHBLQnk%B(Ib@9t!o($yrhpdiMdqg{Wf)>%okX$1{mBJ&NNamedj zcErXUzbN|zDDa`_I4j1!FMqCjFRrOko){Yj4&<4DBcHHf2XnnN*T4u^%e4k4kSs~ zRg*O8pt$(WWTfif1B2FLwqRIp}LKDIN#Vrc3@tH z6;3rr(}QxLZ6AXqIgwl@FkG4+sXrqkE+wblhhb&NGV=x(EOHAQ^X*aS+0>Y4C)q7C zF=MRtY15crzOSqR$DV~H$=^vx-yT(dr%-05i+kQ<+`O``f$qS-JhVHWID-9@v~si7 zLVUm^e}+U0gCm)vvt674SBRJ!xi$_ScD@X^(^HLUgj*t-vG!n8Hn7Jx?u7xQoQn<5 z&B$)6N7mdc>bR6K$G=J~7^zJO6U=h#$O~`YpNg7#d)F-V0b-C!A6KWfWcsuQSg1Aa z8@0B1g51{o5W+@uYqee}ekSRg7LG0kKu)dke-u=VT1)GhMSByJNL4~L)Z4~oz23;v zruM8r3Kz!4&Bgt{SaOtXazn5BO%9`ye~B}cCc_#i;quQMXh~d06y>QeDmx9GBMNiO z%ET$f`*26A8zec3HI$3ClfXaixcFH#DiVhP#N#Wqlm=Si$aMS_vBd|hJEO)U0TUm& zf5pr6i)p|XQs{)k#unf_P;%5?eU7fVPLd~<(8kO)oh`36uCU;a17=dDRmBJmx7cPP zGZ_m!rARI#V+7a0LQI2&(GhCb+_6P-|w`9L}O%ank?i{Z#k8#@c z*X2R2LdZH5z)Sy>&7s>c(z72SK(6@He_tZqDjWR;9eLa>^weSeV^Bd$r+V}DE^Mn~ z7EC{IFkZpoY_=;qPhi(qcE=W1UKk0#zmx?{KX+OQa~es`=4XgB7CEA!0Y%LXz1b+-%UYPP?e6lij$G_4~%7E#(e`bUK@W;z`?7vg6+gM=}&zWv9!P99Vdd|bl_*g_c znv{rSv}6Vvf|4&?H<|xZmRlZ@w%^j3up;HEHR*d3Wwg9~3#^ipIO#>ydW5O0`RU@d zINl0Pp1+E56uRNmIHoiTR*WxNE!Hvj@NC*R_ZA6hL>m(bczO`l=seJlf5WTcC-~=) z=6=201|h_mec6l48Is=rmxw1Y9)ZL5x3AYVB&pe5yHFbWUvP96&;VoCs-Wp0$Mydo z3FwpFe@6E-KB3GzG2U3PL;`lb60VOp#1X7P7`VgD#^ZbkR;@RS zW4i3IuJPG0)Fv^Lnbg78KV4XT->{Fi%EwnK#Ow>y6VKul*00yO>*MJ|UYbfo`?D9sc@#u4-k({&E!?KloiAfA}$#0=@Vo+)Il+ zRa3k!0s0_v+4`D$5D7ccw0wt^mjb(d*i|fC^b7&1elEdY`S|e(3%)XRv+)J zsy^%Jv+Va`$)o;HXuN*p(Wwba~5!K)5ox)Wq)a63|y4y@-eXp;iM9+G5`ab=wY%fq%I)zw&< zmSG#ykK@PaF=_31--HVPbU*ZBSHv-d*Tl?XEXMI&VV;(Af1UjG%>BK2W`#KmWPgmJ z>v45BI>DSP_sRluF4)^0N@0IFg{qFLyS|*F)t)ScFv!tW!tYNn$0g9Y&EI5(m*e8& zp5JuFv&-o`*1o!&B9f~QS>@((iYQSN#`eYKbW8HP@?qpLH@BP)8dzJ7J0QlE6FJJ> zTF!nzmqo3se*~|_&~m!lZ@+?`{R!o3ZNS&s6>Ns&lu3ojV?s;$Sem4g2zdMrC2=Zb zoXH+NvTQ8A!5BYs?EeqTd9PE3zwE~1qN-Iy@F#)a12?liqONzIt_gsBMbnowYJ1MV%dE^se{xY&yjr3 z<$x$Ie}8|eKi44m&X#+y8S}$kO_t%vYdWSA5P(ExeM!r7a@nBUEuDBT2ACNJJ_ zQM^YHe<=24E>SOSQ^vQ_2qk$fiw$rkEJP%^xk6qLdH`L~aU7S48c6l2C3C9@{^l`8 z^;(%VNZLY;EwY7hg6D2b!i1OPd3-OlF>?<-N{znQ^2>)z16Zv?uII%<}J((ae73`MRzG8{LB4VTl=NaoVP_k8M~ za#)~Sb!Z2ZQw)YdNxi()MUR=h*>)qBe?(~QYx-ahq+#7$-olEO3?mnc<*cqs!|{i{Rt3BoNs2RTjf06wD^ zPtnth?~QVU9D-|JkX4!&bEyPB5}Hph|BEv zU4SLBuA(f3=>>uxR_Rk7DtCeQfBrVu5dE?;tAoi`jWJ8XJJlvG5a-7>Uncl5SE*;%hP4;$w zCtJZO(OvqwkGT45Uyo`{uG?9=C^*Wl?)jS=V){uGaN{8e8Dd-*8e*P`e~U3-QJbTN zARoIMnE-n%YG>*4K|4)xcd~lD-l)48zX8jwMpIOZ(m^RsXu)kVvuMH6yLm zB|wwFECRdGrGuBu0A#T{%)I}{Qs(rnMhkKVEG5&rDFISXDCjone>;WHin{}m#2yBfA6!c!`y|~ea~W}pfV9VcxZQ` zB79MBq1oF7Anbh2e@us>6RKeH_Hi4TQH1_lE&j-|f5IAz^6R+YpSelnKMR;hfA$x^ z2g7V?@_0#iUmE@>FgBfN;;kG!%~Rxwx~c` zb5~^uGC#Q<=FG4cv=ENy7CRvV>}A2G5dP!YF(+sye{kUgWKYbyQ`-Wn@sL7^ze@B~ zKT7W(pG%z$LFy6_MwK>dx9a{&$K<41iz4tBAqyRG@@oGSVr~O{k-|kn0 zB$j*D8|uLxLN}6UT|YlY|03+)(fkB^1%Zzed%- zN&i;ze*%IG{%(#4f55hyy&v+S3;)tNkse7)?ZVSW{oRPuRInL5YnnzkLj?qnq_R7! z)u~EI`k_B6z%Nsqzk-pzq*BowQg61&WH!j7LKWrdWyZ=dtjO?Uq|ZnraWziOn}Zu; zi(T(@C0A4mZ9BivR8eF7qh%MiiLrRLk62p%e~8$8Ent&_!hEQB)&($$T`EM}2kDglwbI6yJlhY*7cTL z08m4T1p@}G6_ZfqwWnZ8qogmsY5N4<%qLBxG`b_$H4W&DgCm`>c#FuQZ%>qy|FFwY zf4Tca0mz<>KbnHg1a@R;L|gf!hLV>pI1T`nb0L5V-eeX30aS|n;?+$e1t;1Lk3VCu zdrZ9J6tn0oUN>kS8?D*>3pNb;8tim#ltsLes~2}wLJ*U~WK$LIv&}AhnyK`CoT=D! z>6JmLkd^-S=1T?HU1fn^D(dbq;*C`5e_7h9uc}ZvXdp4hz1#0FFS@9k5NAWFl&7Lp zJSh|FS&ho_^!|~DsGyGJIz2jv*B?|)|k2Po55mNYNi}A zyRfFsN3fUfM2n|8N9=G6%}ypGTA+|sC;YBD8_-T}+~}Mrjul$46KYG*{!=MN=Wc9* z2Q52w^0G4%Vs@5*ql8~(XCH|#3p=1To%hC2Q8m=cQ1>8PK!^Qeo#F)P3qVjV#v#0y z?1aqf>{8$3q4 z2KlmJsh7r*{b$yjrJf5nMEbjZr*cbLOIRoE3(pA?)Ziq;RXP>XjF}GD>K(UHM_|e~*vT@scR~v0 zYQ1>Cp6`$|#P}bW4d~;OnV@~RIxZMUf6V8iK1+RduWbrQ1#cz+K#Y{GMKF^6gNy_^ z4ch~lbu-5@6kG?q9tUtY5(Q!JrG-Mg3wFf*(yXj;j!tskKx_q!i&j`t-)@L!QNOp|3;lH#at(|e?x}X z+5Uey>;H^}HQm+-IeN~ZroDx24Bag-k{5v6qJ`l$3n4UE@>$=KLRC<)*xqMTAQqwj z4F-tL$HvKxaO5!ZJ|xt*_IF?EA7@l;B-;Fhw+R+yf)RIJ{$?_&Xj7fSiqM5j2<2XTzu$Hl#YcngLz~8=aK?=f2lSWbO@?c z2yaHEe}5O#r|@={w161ZJL^E7LkkmSooJ$DZaA$wN@6xWooWfADE_MH#H9Squ>SN2*FMJkOFzvnhKCG!0wNRGHwLDNCLTkCd%y%y?t6&$Ab7DIvP2L| z1&d6w>0J!H+tAY**%(^bAvyam#kt@tNNq2;uY(?K5r`!IA@sT;R1bo*+WBJ{^uE|( z6hVynIau#-f3Yn{9$a$|6G-(uL<(_vM=s27c0kU{fNE(WHmi`_6S%o&mhSs{iX7&5 z5UWnpjm|k(C;_oC0L%$|Ixd&8qLc?M=?)ER6i8h3bdE?5MV6fs_Ct}K3lIqj*qqS{ zM}-{`U&mFCluyPrY0!Q5Ot_WOVhZ)g2kQ9s(1dW#e;UnVW%U%^EK(Q!OL4Uk)^dkW z-7*>0=80xr2IS>%EYoDytE~bA;2$OUEuOybzd$mH^L`ige$F>?KFk*^;ybTHN7p?p zIDCLK;CgLm7saWC);1mY?g(5o2n20NYu~;-r#0-M_2QMlVQUZtR}SVr;(64~)DH(S z7YN-zf1DLK2uzXa`%Ph=5UXUC`NMfJ#br7ZER}2}`zs_MpRihTd0-d*&vQ~*p<;Gj z+_yJmy)e+tFtnUYXKZHpgR^TyJxaKMDpR1)2r`zl#q%z%-1y33&VA9D@Y8TwD;vn0 z`QOYzlQ%5~oY>cik(X_~&L-)(By;ArD1@6ye@9fB`8dk^nG7b(>%mCycu ze<=ym0E`(5mU8naDcDo6POdtK*hHK)GqF!ByNSPK#H&vJ`Q(!nlb3nDP~eovolry? zY0Cs<*Q8_>E6jQx>9^U#uHa?CWk#Ya8UFo9!m9hS3!@gxJR>B+a6ogyH$yWb3C<<4x(qui9AvrIF66;f60@T zZgLD8NRBM=L*e`Z|D%GX0pO0NLUO0V1UX;SyF0fA+`bZ1=J3)s_0#Wb7dIyJ30nuJS+~e-vgPr6W9W z%5#u+W`XU>1Zr=^=J3DQBroqDt`;2muAwfoY5rb?Gv}m-Zhgu`r87I*#`qp={B$JJ~nv6F9TIdZL&w$*NuT{3ItQ zDIPm1+DzWqftyU?7vD_&fAWp427|+-#XffE1O+tmSn_iQT$|={oFU*4jUz!fof55( z^38Ik?F0L_2QGuY*;D6p+-h$w=2S4{8&sQWabefq!E6#{i*K&Ic)Ta>?8x>!yA`vw zFHU;G4N8v^Eo)v)+J!1W>J$JrK*_(ZOfXz+k^W|YHh-SfA1*uJ@?p3N#D7I4_9rLs zldGUMar#t5_8eV`0~tcoehU5s{zd-|Y(FLd`xSK5dkl38MCk~YI^Vlz7TB6NqpwZX#YP($6XEh{(7Yrjit|Z~#}|4{e9hFp??T?xEVYo0}^Y5Ea|bcRdxBt(vGq zCtwoLxm9C;!<76G?z~8ejepf1lgVm4N6!_dg5{a(e=4|QFYo<=Y~H_B_j$2uoDjP; z%KD;S#W8a37G%=`Y;COW0ZLx-MkrJnj-1ki`20KR8X2pOU!b|loijgg#DE+#m!mO( z{_I{X=dD`IU4ASQ(_Kl+3F(*S8$scG!~O_L0UHVDRqm6G2)o&R^_2qn?@k!t3j%#0zhVV+K&#N=#YOjDz);VCK4hd|;U7-IJ*f=Mr+YQWhz92K@On=Wyndrro5gnf{tQNXA zX31)UHK?%U^V*f!8y=KQ3U}g|7p?K&hEIpKE1{X}L0K=p$UTOK4djK~&%=JwE|*fQ zutPi_RgiGfz`C!)g+x*lO$rvv%XuxX4QTj^axb>AVg6p$T4~tVOg5Er=~bN#n< zr}@+y7_J`Rjv!R6)_SOiLbp6D5>nTncF7fKkz7ihnbZ)(KHhIp$eP64*p)n%sQY`Q zQ8HAErY7;}#3F^s%*% zl!SK^`bkim{`bv2Fbn?6H%8%_$4sL}9#fOC!XJ-h19FBD0+w7N1Rs!2#f=q79PN;m3w$+b364uC?*s;Dv(;spWwJE7u*jWr5{eRlGUXB#=|)cL|z2z>&bDNvwS z1vM^lK=pbGhDvXBJG`vnl81!JD-X&vUj-uZtbahfe=88zSv#pK&{BT#-wPdDO=!KO zSmf&=QsIpOVE`ih-X|&OuT?<<#0hGE+JFsE555`ME(s~63oo*6(DlN#vjoaqx&%_3 zV>x$cKoCtq#8xF~8wiK+C+T9YMUA|m-tdwPc_GMq9yLra@d_$}y^Oe^BD9+kZC%h7 z6n}Eu`IJSza{$;}1$HvS*seia<>M$apzn`*8sWeX7gkZ)L0)P*$YOB^nf^~$5CNL1 zX!h1fHJw&p>dL=>B6#q|jG6>|Mwq2u~q zzFYRh5qcpso1$Gg>(?-&_aUQ#AjAkMe1C*cU%pu&{VrJ`&L9?u)Bv!blbWmNlF{R;ZsJlkJyPm37l4r$%lepRV7M=8eDfyL-hlLoN zUHN9`5VxN+Jf*>CKZ{0ztxKX5_?xT4e**ESIsxU?Zo}Q z(;9-*Cn_dFNgN%BAFD3P*nef9@DZQlv7rmIGBuCyIo2RV>OsvL1g5ZnhpSQkpDJa? z=|h!j39=Un(cY`PZ=PU+YMRQC%v(7c$J9&(EJx5x%Tb7MIZ9hzjaZjtxqkp6hlPhyGUQ0wBx`rm@97#OeYS0q$8NMahFpZGV7l^8Q~=Oh zILJ*XDbPC#=c>5vkrJA{8l`0XdWZ z^dZt5V}u&2lbXOm= z<^>t0G6{z6s8es74pSfyKp-e+fM{40iK3T=IRPGj9s)rCgCGc$VTg*y=rE-N3em{{ z5XTUM3GnhK6p^~o0GuyChl`(7m%2K>MnrWFBflLOPXDbg+ATxw^NblF5Cvp>V54ql zj#r;Onp5Qu6kTiDT=eJnKZ%c;b7m{EqvK^rbj{`a$FI1{Yj1x0ogwA%>My{w*vzS^X9tbp@UTZ-(kwW*Uu2VC$neHU^WIX? zRQ-0!kYpbGLhjdtUhS(TUWnr2)Vte!=|LggzjyC-9g&%#T)Bsr~ z-iGG(Xi$~l3+8vMftCzn9o3-~v8kAy84gG7sOVV$PG8l0qbKHA2Zd_9_Mly2txo0JFG4WL%7J_-&G2yLgWCRu_R+@j+TzB4 zWc_^a)|3PeWHadzlsw%F)T8@V#lH=&Ay*?BA}K3`F>(vMC*sAd+A|cEi%H&<7-wO+ z*RJtTE1|rm$71Hmm!kvrPAxkY@!;`;_9NJKVkmUv;8+0`M*CKN)Mrfi7JglOQso*5 zJQ(ajCa%0Okk`3@#|A+)JI$qK(3eYpNie}^-w1-VZ{bfOvl38~qE27_cBilB-h(@{ zIP~Q`kqf)qZfJWdj8mslDZTDQo&}`6!xp#6lJ$$l%*J4Ws2P;egOCN5S*K3s{Y19| zQ=X1rY}}6D)f$iANKm|{$M19A%~^yy!p>xa(AYVV5S7X7&EAq=^Z2@AjZPtd!*AC} z@1^!4#7u(;o}G!pg}0SC4*0&L zc%B8?tUaLy*lkW{R<=6A$_!wC_vrEF&I~^5hxVKqbwX$yK|E-rmHk}ef}PoX^D2av z>aU09%j^a3@II7@cN1L;jQZncGedxo$3VU>Oq@gnILz`- z|5qeLUMtNs2}P%^G?sZ*%vBON&es;whq#vmLx$LR?AAcxg$vQaoMJV$u*s0pJLV(xeNFad<4Xz^>K7|erX1ZLuEkI=usbL8cv9!+B_Z;?pJE@2^j3CAuz2{UT~Doo7j{ zS}<>W>d0cgd2V5(hf%&2_W9wQ$;{l!+_MZE6PH+*$|hzWQfo zSS_-R{WGc%8_G|^FLk)}4j`9R$+>afpA%qyrBDK+YYshs@HyuM)XB5IqRSPLJkr;9UMXpAfr0-fjOuMSQNOt&jv=>m~b)FMACeDLxwq2)MSPQek~d zm``iuIO>Q+=zN*K6QUpdQfXA)0@=&Qjcm2oZVfTq0;j8zQ`9)tLJg7$KH_ z&jHHh1X3H$NYlh@6btH@+r585qa}_sZT7ZrssejFh({s3Lw4I06dDo{u`f_WOk@03 zIT7dzdbV+BXH3UA(ods8F7u<<3~4|fOOljnCRS>HPagCJTD2zJ=j!NHqP%*?l{c0> z8r>q*4pl}V(x9`@Ps)-5rNJ%xwO|m6GI7hFkaI&9119sS8RJVRJaH_YypH3k_T&V^ zoV|LjXYygh9Nh+1Q}2OE;GJj#(@YBYvYc6or=_uELF0J!hXjDA4rM(s228S^;LXy0 zZ#YkX^B^2kI>?HcphRx)DTekJUBX~I4uD9Mha1w1&Y}}0Ag1dlUKeW&Oq3XKRGO^c zQ@=Kau3?K|mz+2Dvqbu^u#0SVXbHg*x$(%N(nE5b zth~1et62Bn4DFS3x=8^|Y(0p0mw zdBTHV3Oi~&(*1p8c(YtN^Iuo51h4pil6>kQ-*r8DLmY0WRO1AWxC40}hyttNSdV>H7Y8f*OIukdOC6^d+tUa`$unFw z3)UW*PwprPZ8_Bh$RV_;S$b#ky8&r0OGuzi#rSNZ&pM(?%g(ersfy2r4Q-i!N?o6w zUDq|%ZFOX3t$6K}3$|UQ6!K42P>>N;`*TC!BzRe2r^8(r0Z!U5Z7^F3d^SPH9II1Rk-*d!Zy=0ofk3S7g}lHxP-JGBzY?Ie{~=H9 z^8r_ME;$A63l2osWA3=e9P7&C9o8U=5g5e`JET=#HpUY*@7{p_zJP6iO^suw@72-z zpRkEby2Sh%HvI#`NoWz9nJo_xo9P0=YyXB~dl2yiTA5AH02cD4uDM@ZBlH#Z*;yp}wn#2^>T9pbQ=}>3$|6Be3<$O)xXb^!;7FFzcj5 zG_0wT(04hZP5Z9R;IY%ISa$d*$hj5W@23C`xqeOxE;@><--KL$Sqr=o@7oj;DEwH?|Sig;KCgbJ^TG_oJb6IF?fP;{+$Dsox+_(;h2C1<)>cjE2)P_A|;i*Wa zgtX3dSTJ$>=NuY;(5VM6Sz(Ef@!d?V?bK1~1 zK%bH%%76P1-!N0{^o6J==p#-|!@ zMRRHlkZmo0CW7M-QAE zC0xYtHXVtlpoSn;a0LFdU!E+m*Yj7Bs;C;8?}d|yLG`_qKFpZxAEco)uWFXO`%4Q( z55_|GSN(>>5CT}r!htZ0e-Y(YBb8ew>P#?yA3@#&OY8W7^(`>z8^H~tsRx0nYKgu+ zuxe-=QQu2NsWfhbdE+S>*paei?4iD>#Qe3)Zeu}LUQ`U41|V}iWVII*41o#y5}%-{ zIw}2fQFCyCT{d!YU%5{k20y z4OV}1cx0Z-@=Xzjx7_6hPb3mHbRF+YaT%}aeYsBEvOnDp_p!tAmY5PMg+x9lB^776 z4h8Lm_D|xX#5TH5Vm#|dPVW$R`c0KN;Bg98Uk!2;==|PndWuta0uTCmIH83_FB4f@ zTYyu!D8XJ_2ByZsXcSb^TPDsI4JG$~dsaO|XiPQ@5HS$ZZDzHqO)&?;SLm5iL2;_h z#t)BE#pL6+0X8+G5Asna=+7bFhfzqKIJ{40r?_9p{;g*l%5by|va5 zze4Nm`}Iayt6bN~43NE*;zajOvH#Jxu%%+I=sz-NaYuXg#tL7%!uU|W;MOo@hHqS7 zvWdM)(r;r#V8X~o|K3pGxH<`PaV*%U)zk-G-B-Z*Ayp(c zyehqMp`w~^;&O|4Z%S_?+*4Q8U&h;Se|&v+c)IoMcbIoa25ljWWh$h9OM$a~zI8Nf z(A{p9nq_`Y|Cy09KS7`iuz(oFLV&3%d%;Y`3+#c87HgQ)-(2}tyP~UjV(HvU4s8PYhJI~7H>g;n;ECTrXJRf-4y@}l<1FI)_ zR{w{OqRsBT2t~(g311|CGxI{S#aHVUCt;ant);l}Pp@PQxQ#2p;nE+lEot9f_`Rvk;~(ARpB9|z!|)z8>P}U^0c|fcG)g^-7-e2*AYs~XKzwuOImK(%E|ns z=I4G85;Uu0+o5vjsFvjZ%-ZJDTaES*cv$AGgTTIr4m*cUv2i1R?Sxg%Q6=%g^-^UY zKxHIzVS7nDPP0UzQvX;2hf6vsoMSVT0#PbKh1a=oAUai=HoR@%KFC>?$^*gWxlm;P zf?ZIan1tE?=`x2fu7kM{A?#}x55dRcWb;;}onk{#^+OUe%t?3>8vngV06Dr_NaYT_ zH+)lEOb`-9ILttQZf|Xz9i8j1d1=@*zCI%r(>YQoPrq%cEEcOpe=ra5-}^m7!N2!S zG*H{wdMB4>I2sP6Rew$wyu2EbKBf_K(VbK}Ys`{M3@>x%ynm%FE8wNVz#T8#2t z6n)a3x}~y$o?L=1QfuhF5eo#C;f&o-t|U-cC1j>sRW*P%4qr+`k)M=?dEHk>)x^1? zQr)g+q3ANeuxWOe2UrvB(MOFjDS4cpMqH>l0h@%<5hD14PB~6-$ zhqdXm#C?MVT42VcCV}qkD1=M9kYVXZ4)A{-=mUw|0kIwBxsHY-)tvNjvq?8js1Eh% z=nP27ZpXLa%wq;-R^jro{L%VoOhQAC7xG;mVci~oj;F9k$fNTbw^iI$N&jfKabK&f zlJ212o%>;eB8`n|ob3IjwwJy?&}kDU4MG43;~%`c08$1N5s-ZVQpp69!O(0EGN}et zX%FGuTzMie1Eufi{eNV(tTw&zy~mhAJCnQZh+_B8r!T)5xHm2P_fHzT78uda)&EiF zW<})-9Gd|{K<8`OvxnK2k4*t7f50Rd2n2!v3V|TNK^TO{P)tO2Fjwhz1~z-=8l4*9 z!bHo|SD1GTwV2ms3FrMzK$NqjXC;$3ILo6&;))TXqwe}3-%0Q3r=zfQOhSd6=|-wE zwm>LVW;g-wXv!hXC@a81x@cyzhFQWIb<;FeK{{qM=;amaYlf3{RIVW-fBy$-Sp2c@ z7Nnp{cf5t&s_-Qwbbv=X`Mrc`m2QRje{3C;3k2Gtlbn-2Yobd7REm|wV?sA|tO*@w zqNiiofzD{x(u;gf^CTcR(gEO7E{x8zf4pTSc#h(>-5(e5{lCYSbGA{T*_c)bd+t5Y z;mTxTg5{Cmv(y}d$bMy&e}z}hl6HJPTx#a%E}%?D>GNq}s3AU(FG=PE$ zV7^~}mft|#_)x!nif{{*m{US*8~Tt5>LwUJYW;1fb1998o37wH$MKjcu0{eOa$ zDY)aBg2Qgpv$62$0a&`qLTQm=s{a~k&;-Eow(H^IE%~?6Ar@Lqx)!dv_sNqkTtvLE z_rflj&^#2qJ+1!Tb$4A;rKnnjv{9W1ZnZ-sD|?Mt>B zzs`9B4ecWh#CQ_rcHo%kyuyJG@a!AnR8|5Gz$dqge`+|T)Z015mhl}wokCLQ{yNB% znG7s&F}tbok8hZ=`hVN;C#=wclWviJqx;)*8~OhRi7UJNh0DgezuN$GqB*7Husn>W z+rwA*<|bzM%tHR5$!I&pWTti@HAhRciVppXF4S2farzNXINC3oMrZzP25#&#sl>VFft@8{PsCRfZBqanRyu?~G&e{b7y%`LU|tIViBy{2ooWv$Z6)woVV z)<1GUZX#*YDV`@MJ(=S5G&6b2avWYhK<+oWRwR7o`c2&PIFRc($N-CW#z#%HsfIaE z1je89O{~dJNT6fyN6VwtjxOxc{p2oQ1}IZgXomMboZ-QEh9?1_;T>`Hu=S8CqacNC ze|VCxwRF5ofGVy;g_aA*h$aO&5${El8S++VT-yi57fN=QZKnea2&f0ydk z>+a+@;*PLyGypc5^MFo+3QpzKckY-lo;wB4b9VxwvFi_Y2r2^*vAS^l9PQeqLa*cL zCBZALe53RGx?^*G3&bS_0f^)ytvkUx*N0gbJ*%gbtEmoJZmMIf^6Mc}SCTn*;nQVf zlNFt=otX`NRh{ktedaxzP9RgMfBhJ|nH-47azx5)V9D;3)a!}@=htA?op*IvqH1zN ziS2?|e={^X>8RXHfz*zg_geR*9b;g0z`!syE#wY2Wm?uWQb7Kvy&LzZ0QM-S6cX76FBfgvxg-VZH(#DXKWjW6hf_z@iaW7S;5QWITXU04F_zlzzKKpnq zAz7}A956V+3&G6~e~f~1h#RA@97|J;1eqR=n5DapkJVx?eo!b{HtKFriEO5*Af~`k z?u@Qvaj%7x`M!pY;}9MG@^J{Xd9R>2JY0mb`M<*K#>twQmrryel+>ww0&X|TfmaEN zPjAho`D^ZXPR*+v6Z~)6ka}@fmp527Eo0SC(p&csMsI0e-ym+gMIE4gI5mwb6@DWw#hz%GlHN6jUdShw67E;_>M6AljrE@v+cXO zw5BR_`Ev~={ESwe1Bh(|N=E?aUS^bkz%>72xH*G#1eP^pYZrBO_1Tb`nI?!Us0kjB z)l5yWc$_BrC;Z4rFu@}r74KkzHT*}nyOT|s34r9>e}euNKoeZO_0DRyQ$#pFNXl?s|P0&xJ>Y&%KEp<2Up zWfBK{l)dDyBYiny9yBE_XVL$rf`P69SWzn4dD446mJ(d}!Ac ze|OWD3>rrPB3%;1sgn62dXs~-tn%_xWHG%lp&}Mr8&fo+)A%Z{r??-sNogN*M0v6% z=c@k{wr3)d@n}`$P}HgJ&r9v-1;REG;Q?&|0m~ySTb-v3sz(%HqOwWp>&?PMLQG#1 zR$J^paKD<;GLv4(kW+5T(6%fi2znwCe*lkkLJUi>7OBM^JRAM6TXFMqc7nFIy0sj( z>9hFp$F|FaKVs}*px(?c7K#2`(*-M%ST~|>ZX_oV?rnkZf8*4}Q^tehdjRqD27oT2 zt=VxYOs058JUbr1-W1lDQC>g;5-T1?P)KrVl0>x_GA4EX5jRNp0WHQ0*eNAZf9X>? zk+`CZYSbq5>?-h5)i#nc?ObZcGWk5zTuAjWr(;pPZ6vB#>`&eC#HQBW!`5W;lu%E5 z{=+NakFA(92Yk&&9CNBl!(a{`AE)TroRZb)mOuOhI+s>bIi!doo>86D$63LBB7+aF zEGm#~29EPZblw5>Rnil5d7U5qe-p_<>#g09CKl+VeMh3c0k)PlwgT&=#@Rk!Y;B(= z-u6ks+&(!%^nD@FK5q#0N{B$GDG?|lD+0CUMW8_1<9jJ1(C4@klftK5|3Dk^X@yi-Ek<#D7(0Rvl1x$+Q;mp zf1tYJht*+x`#`NS??LHm_dvDfOc7~P*aBxknIa5M>lv6!&lq8_Zu3;Kmj`Fh$oyGL zm1Ru3=Y(*7WE2!Uq<0w(I*jDcbmW$l>kTa`_37hP?PL4HAiSWjfBVA#o&%XfvPXvO zj5pyBG7Fd6P-Oua9uy3Z17OK%Uw9iR%^OM?6lIhYU;+}0ge5(&sv2lHRR zmrNr-OIEngUTD1pe`aR4Qvw0iq*6kJ)BN#C4ZiP(7x*rYfp*4%jT>7RfR$O|6G0aY zfs&TZA_;K~cA5@u8-7q1NaN-&R+qZCQg-7$7fKedFLNeyWA8XjK4_nG#aJ1Wz1)Mc zgt%|jKPf{<=VlOF&pc>hCzP8KMu8G|&?V8AcTl>dh&$+2e?~sDEvPtoLj(1LO^Kul zL+e-AP=6&%;+;LTP>khRL`58+vRi2g6#|M5Ap_npiwCV>`f1GXRv&jS#&m(r#j3|J zgy-J&Ft9BB^SA#H%27*o;@+UFD%CU~-4GfcSLoOOpM2ZGBMWoLWGoS`fqI^VwiqWg z2@S=^&r7&hf5V%%c#*E!Gw71gfWj`cV8e+L#}eAoeI8H7bIN1km#y&N zO#eEp(uVVqi$MUSB7VwFTG1Njp!-sdW8s`O_3>f(?|M^vU4Kgc`0prI(Bc9dt3A}s zgJ--6L4_ zFugxUf7(T(j`#k>rhx53DZ>m)dft5qPYu@_I~o|*mfpK=ChU6nTmX?r*B4TNkCL{3 zs&^ybTgqdA3J;ZQUVh-%LwIS|KD0t`J1Pi;$m9ump*v&h|JQ>7TDxbl19jGGZ5R?s zR16XjKynWH4}|%uI!InwH@WnTdTMZ82VGv-e}B2Af^_C!#!zlH}N@mbCHs7QleAa992q@fGh=@M}SI!r+#$zcY={6~vse?YgK z?$rmN9aEE3aR``0^?j~@AwNF_hluXpSAZ9qY%i*_8fUd13weffdmqD9_|mGQSA|@U z>IY?2Z85E&5kN>@WZ;p33L57<0gad6dc^V+U47;H0W2L?UoDJH4?z1LX0|i>!BHGn zMGFnuPyjP+Y!&5pIHl%Sww{GDf8d9oig^!OtMV@)&^=6@Cd_C_;IXRn4{9X~MF%@4 zAT+Jc_cTgmIBNyKtwT0t%Q2Y19mXaqIQ4nj|AhSVwDDib$J6o(Y)3Docl8Uf*zpri z(G3qlXtIBH;nTYm(?-r5g899Dfx)AgkN+7nB;vN&YdOHc7fA?pz~ zPtivFX@uvGLU2gy_uQiue6GQ4jJxo;w_a%(peYqwUK7dqGdKuYe?9j$LK4pW8J51! z?ky*j+i~q4x>$0T6IQw;x0BG~2)M+kO+th!$LxF~$e@d(8ohs`W_Ct{JrHIzRP$qk zqMKJaFb!akdzA2`R7bFjv0kl4KxVEENnMmBEzP&~9&s3$MEgRC!i;|Mq{$c8zJh~1 zqj6$?dNs8oRJZgHf9#}g_J~U+!;H+U{o)^bbcqtDs9xi(>r`M`)=Z6o&sEQcn9 z%$1y^A^3Z<12}$)2z>8*fe8dKw6PrrS9n!pZRhRnYjWqnf484VGiyH7H`E0nUqJvx zp?Dh)79!r>7FLe9029Z;TS{WX@>Z9{5-D#B0v(gLw8Jp+R!0yOkhda*RXW~McDz91 zE99`s18gx+yd~?k5b@SDoL+*sLV(~?9Idal_ntxQb@V14-a+Y&0Al_!U}9JUDh(zs z5}N|QXZ{S|e-JT5k9;$Mz+j2c`CI_KVA5SmP2{~@IaAN5(bz|rm1T640H#9u@1}Jo zEW4t+NxmJtgg^WX!{*`<{xkJX-&kcXsiK#m!&yv1K|vKa`z1qGSW!yTnBo1)PE%Bx zB@_uWeq{GzcsC?$Ityf*WVkYc=&aRe^;IL|^p6|$e=rwQ&^@G;DOH5aFouC^-vw>o z>vRn2GO$?3lGub1H0z8QhaHJ>L=X|6Zy-g@{z!R}k4HlB1g#35QI7WNX8)h_ zBxt6>j@F4)3_2)9l-%D+NSuq%X>-`Uy?1b*tui$aFO!wZuVVy->!_2@84hD02w2eY z7@;i`mltIL4H2n2$GU==_h5Cjk`1Ofp;AP`W50)?Q6HB-_7PnSt$0ULiP zHGV~tKpazcF~4V|<$ zvRIuOPGHp15s&f^5E$9K2|c}1ywvHppwlcg0;|UNx9y>jSxA$^cX8-(Q?Fd@Y!XdF zBsqHn(KA8@qo4T{Y^)_@Y8oduwy#ddJ9%bTBvC+v>%4O@SBm>s9{ z>o_#9!u6P5j=AmXJ`@p(i(j>!O!cxhW z(G3fi11*J7Iw+>~EX6v{Qo$N+v#u1Ct{arRT=E?lkr?De|G2)TfBhNzZ966tQa-|` zL27T(`t2YWTNCCaJuRf27+Zr=`epC*Hw7OUMF6GS`1n(D+?NWAd;# zo5g0Y?$5L&dVsV5I-fw0dYiot6w6EKv64?%Y=LeS&XkCNB}`xjr*4P=%zeC@og7Xc>LY1LKT&(Pcp|(YW!senijrVt0Q(JyW=sM%zDvbO`af z1gZ542NcXt!iY8;xef&V16a1C{6Ch2+CK;GFl2wD^$uW${0oKOct2#@^vY{XRpB}n z2!#FYFk+hTlZc!Hs*qc}G9VXaH2;hJ9kwatqSwLfcK|0bgK!ZnfQTQ1HB4R-J!@Iq z8mfP2&`Bxz{y~2cGQcvwO<*{lD_pd)ApnYn8OGWUMy8~^*^O=ZCwTtak1IlenhXt~ z{-4FG{7n0H{1DmvAfGSh4c5$_`#0NhT?GYW&07E zny#0y0_%U89K_a81QOmw>a>^yB%}U9s{YEeLJk2sn7b zvCuCbstp~cI)QEUIB}oCQL+z8qZgO(z!n%V&iP;XBQLaH16j(@h&uZYcrwGO2f7PG zqGH@#VxL%dDG<6y(h#4)(?s=#b``P~ zE(|i-p)^j=8u3kp_vUJUiEG3jiMBZG(D(=w%|J!uFQV(Wp~WLL)EH!~BdDT+Qg!bc zbe3D;3m33)xJX~J{;7OO7DdP{wOdIa(gLIoNoa?ipTd>Hy+iHI1>3|VivP9J5kcu3 zZz(umQ(q})5BwvT%UcP(8HyYD&V}-tPxDjS10eVXrLy&dw7OXDe7OK7U@MXpwC!jKw}F2-F$DWre31hcbHgP=355h0lsLyV#SlrK zvOtU#$3D=|gb5(eBdLFJq<=`@N5)Q)3-d#2%=@JTQRLdOhn|bVLEQ)SB_NlQ{&9Y1 zuD6f|o5@-YGXvv}4$fVI*<y2m_#sk0d7(OHR-A)fxx*Lx<$RYoPvv zydi31T6VY9{i45NabaO-*=(JnAen!7H0Oh;a_Y4H11SQ{(0_jo!ZHE29GQV%9JsoH zYr(fVu=U@*0APSXydNe)+Cc#O+4v>uBy03t3UEw8r@%LCH$A}*fGiP+^!8?c$rG$U z!t^II`UFY;ZZcS($=Uy~K2!P(xeQ(76ILhyHdF@IpszhlwtlABvOOLFy(4XjsFjP0 zjz+RVBs56B?X_ZAtbzR6fRL=@Vo!<6N)bDnHkq_RM=LFcXyW0?D!~#?Rc6ccN{y4<2jZkHNWFL`S?3d^pC>hano2l4t-rb4$DR!r@0_6 zkI&G$P+sy;%qWf35rKcgKEx*W;ry8%vctOBSP>aI!xl$(!qbdvne2%@k25)A-VoMcbi)_m-c?$+$aY zRxgOIpr`^v*EffXzWN`Htxv4HS^t@tWp zhmJEPp?2^bqWG}sLxl9LR}B(Pr7ri+8g=apQ%S_NAxeFsex1du{;rmYwiuY>mR>OG z*E%;VGi}F-%u>cxEwZ7vgG^i-J`B?TB>`v&*4LGm@r5)JtQQ(n!4)k_F6DUx>5EA3MVp^RS(vZ{4hr*z5vbu#>%>ank`bh(_x z7EX=}BV0>Vc(CdpI37ld*=j$nSLug$cZPjsRLfrN8cMbfBdfq>O*}MzuGq8&lsI z3VrW_264a}F%^0J+iBV}LHk$>^Qb>uK>SCntoo-lTS-y_(i0V=F|j*|$DM{zw(T(5 zpg(HEW(VQjzJP24j{$Q&G-`b;d>`3k1YoUdm{WFiU*O1WE{AJ~PnZHT1^fHY1=bxg zJjOq4|CS-&f;j7YpjdyOzW(1HCk7OS=$_ksQZD#IIvWi~;JJZm^z?BC zwd}}x7>h*g!k7b&0e?PZW(dj7&0xojlzxOp4~E%XfC6|ZrQyS&*MiySacC8yd@X1E z8gCR*D=>r?1EWb9&LYm*gM(t{qTIH;&LZ=J*OAhnMBDbfY1@B@rRgckrQng;?CJ9G zqGo~CA;#m3gti%0k?saIh_o$YCT%MKTDT?LpF%~iu5cAvX%bi%=FXrVdlIC^cYn93 zZ}zH6n%;Q}9-7jR!%ZKR2Sq6u2frrin6DvCaB>|Cr}?sp(>ILZ&>UNj z<5HAWG!%{lYr%hmmjB<9`=AN;2>CR@i0TYls`pJO40L-i;@gDh}2`Ios4)B@UpTO#G`FAAGlZ{^L!Q;_&8%h4+2!FePr|LI05dq%lWaWIsVGqJ&BY}{dmzAET}@;o2u+Bxkdr9 zo83B2&XBD!^cov`FK>du2FAp3-l`_@jV}PkMCMc`( zi2vn~Olx)}O>B{1$L6I6N12Y){LzCmb@kvxwXJ_65r)b(7$qlv>{OZ`B!tBxc7ZaG z+YK3HDrCTO2?{(69&K}ZuTgsSkxc7A_V_f{V5*#53x4g4_WTnQJPoiU4T&*9Nue`& zrHZ0BpmnJ}D5wGHRNeq4K-ub~byFIi$DbI5YaR=Dq5$DFMf~L4aX12$J)9*xFVGhM zDLj7`!T}3jt*jfnf`jdyt@$oy5r- z^?>Y(E(~g*g1g^6cfaC@pN>6i`c}c}FTza1i5YHC7AkeV5&J0X+ezmm78g z8-H*R2nGQL0*7EA7zhLcp8x_u06~C6z(D{(0E{p+gCZ=lZVZ&%K!?`qH75{C!vK1d zpKsQKO>oYm>Jb*`jwlYNxHUiZu#l!g4m5*@Q;(z2Awh|ur`WNiJ`u^h7pum~9`%b0 z=`S#*>OmOlM-WbhMeN|GxPp3r2bc_iK7SEm@BoJ{E5bay?eyseW_ptqXIQRHPt3t2 zSkp(o5^8#845tL(r4b9J&p}XX))!4=it7~lvh>IB&}>Z{hr22LA{s(L6lVA}!%P>7 zKTLXL@MnoaxZsgC7Cz9t-H~D;{RCqs{yut>o`h*gWl|qUzxk3T+UNn2l{I=ij(;p~ zWAqqu*q^zz+9QQh&tue-qUUuCq3B5{vi&2`@Au8bomIETJoFC(r}TL)gAM&ShS>tD z%_MEF5&EX!<~00UU2@Q`FI2w4FPsrl?g96Oly4md==T(+g9JyX9{7TvUqWny$YWIc zm_83pfj1mVp?H8!LQQFPkDfmWEq|m^U5cn^2{j@q@f4Ds$7(=EP44&pv*Aot>?5Qw zv^x9CyJ5lGH_JSiT`oB<62Z?<>xUwD5&-9SVw|z4ZfPJZZ@yAkmd^OpjCV@^@oYo+ zD4jol%?D=K?&jJZl~&j}0R%WJ_T}Z=`;6v^5-vdSFj|sYIx{b9KuhQ}JAX|ev&>T{ zBzPPjn9|4FmzY${V-y_yo>O5yDwPf&C^_qwM^HdlA~%Iyp;wtV)4drjz;gNj3dhCq z6yk7TW*JU=st@HW6u~TCfd%T5QztR^Zfy%?2WG7Sg_l=FGks@-vBj zX+l;#zj6b4O5{>oB3@YpfIQ9zJ9u9G<gMTAvUQ8pSo}!>Q7V=Y6p$S?Wrj^#2(TXHse13!cf`F}0>n(rUz5=zxSv>85Jnq5hQ>#T4--uR+Pw`1=uql2g zY~oL}$b{>}lf}1q{2sdR0t!vNqe}4r5z(u zjegH6#ULOaX#oN|mZudjic#txg@3ey-$87(w(v$>;XYEOL!czW3SIb=x`LQj(=I$B zW^G4+-HOt}&kDTO$yff=>OwlNN`hC$rENn#!^8B8lkcg&GmRQc>{Ew>AUsdjm-;gmK8%58l0*7r=FQRh#ava6$S-7%te_o$1m2 zzA0;fcE?PhMVwi*qAKfkFGQ*R&AkC?tF8OGf>-(pYV(yhWX2D$cIIp>20GnE5F)=p zc0;^wihu5xm}Q$VN%b9cpIdMVj3^wZ5Hr(&ABvc{QVkF__ooGC15Bt+#y<$6PSr&91iXGlmr|*0u`SdnS(bGMVJdGCJGIh*N9TgIu9*qmWup3m9JA z!S=-m>kBN})hiH!URBCFn&*a;Bf$3^*V0a zLw_Ii8viOKRZ3|o_40-@CKc>xr;Wgn94eKq&<=fsNXkMw1nn0IL^+sBY4PmPMpWLF zimp0)V4-^gfSBdegh;0VTNR0BpBcQ=xN2K=zU&2JZGvpoddm)Xuu|ZPE2;jZ?14Dt zHUTSD@nqj6tQiT98E1eH?f`QXNywh35PwIDDe8zSSvT(;YNL&vKpCvDj}wGor&-~U zk_N~^A95RLBB~;K2HLQJ@UkZi%5?-d>_`q};S_9Izp8|tQSd3k%z1o0*pU#o=D=&j zW{ncT^Edr%)odX0vgi$q9w)!fAop}pMm}Dyuj4;>C&S;x*u5+m8zjGNtSve#M1OkS zy5VrhuzRTbU7rYGEk_yb#a)LPQDtf|?)aih*BN3Lp^)^J^1053M2T}^Eh7};y4gpF zbHvXG0t?s87c9jOQWU(kGwW4teA{~Ptg#2o*7d~ZSDouDVW>U}ph_v{0B`E1YJKK{ zTxhHK!KxBkZ*f?gJ?m9#aA$qF0Do?{7iD^J@jkH%u;du>VTi~&4x%j{WNpyyVZA`C zEphloSf7-@W-C-KtUc*Uh^<)}1!Jg(V}vgZMx9|=-0Gh|<~%6Pj@9bZhDNN_ZmQZ{ zSbY)LTnyCP&Mh~-8s2=kZ)N*AB{9^V$31IMRDBr;prKr{_)$H@u$eX-D}U9Z-s^#7 zJQ;oRP)M zSGR;_g%}V8+f&pL0c;Bh|5so{eJX(`^rcTuNL6eOoUbuK{k5U@H5h$(Y1^kiLYMQP z;yS>mPaE87b9JXAyDEiNet*FYLWZpovW(W)%!&`EFM^f}39#0sH~m>rzdB z5V8P^0M^ve^p}YQ&V}2#D*jz%M=>4wAuu0nRd8{4>68$r_Fyt2O}KQzU`?>m6|=l6 zy%fUE=3#LWSLwM7vy5X6qw5<{dLxc`9*|CJ;T(N+Awzu}tt&ez)PEE~CsE8~f`>zK zq}xe*ibJmG6GS@gBOly_^u=#9A-yDPOZVu8EKp&q z3V*!N{!{HwUI;IGUIBF>VrAA`zfwy+NZ*wxiq4l| zC|*^cQf-Xf%M;VBgX{ioumiK_7WbFOPC5Pv=pyIb%TFl|;PaetpT5hAhhlv;g$ES~+e zn^G1RKHq;a6~5;Tu!i@X0C%-O;r}v`z!IgprypOQ);TX)Q1l!E(FJDBbHsqCasRr{ z^+2q5#;tco#*XL4Zi!iyNVQn1?r}GNd_goLtS|{bN!G#Qw6A$yv<@fNjzNo_#V z)QqSmeO1e~!f{uVJ*nqPYvSva3yz#9jd4`Le_|mpspBuMkk|Bs=T*5Q?DFsLOU?bmq@0K8$R!(MBU`G2bZA5*qTamW*p4?-&Y5jB>>qL*S) zU?DA%z!{^uxYK(V(#~>_bT2u+hA&0~T;3vJhpeSGCn+nxL*CtZc3lRkAD%rhQ_RTH5^ptsP)*6eWcKC#&ClwX-C8Vsn~f?AVxPqDn(` zV`NSW$&Ul$M-8CZJ*_A*NbfvF%^>(|EPntL9b~r1H;JxavR9Tln+j_NnQ)q%^Y$ik zAJPtr*@1N%@t$b%F`l3V;HZRYG{oD@T`9u+ha)ZjaZG&bFPjHNzfyJDziUTGJAaIf zuxN79=Reu-qW77jw~OhHhNnbD-#K*G+;tgp5{^T@jjGmknH0Vtb)WrbQxtBfoGd^Y zr1+awoQ(Fn$L+j5v(cbXAQF}XC#j`@n|dvH45&yZ+;u(z|7tE;p=Wm%>&Ho;n60R6 z2B93XPp7pK97R9DXL5N^UBQ5-On*j#ZZ{N7T8XxASD&y_mHz4000F|jO^H+vaXAa0 z8(j#}WT8-P+N7MJ<_kaFTHYLPMoTB!frdf9K_@eZ>+oJwqeKI?yvA^O%}#A-@HQ}U zrbeeE0kS0mcj`2`3x6(HR2SUQ&6O360P*DZBxddph{$}5=^e45k=RjFM1S$-Bnquz zXm@LA@h>Ca<6TWc3-umoO9+$}D#YaxR;9hDk`4V?(hg2vL&TmW)SV>WEn?ZN_!Y|w zON+R&3@kv!e587%(P|D!HC{+yGoxA8)~bjDN!XD%xxkl}%`)0_gGg)Vk@&tg>?l}M zu5Cx6tut`9L_!qBqq;qqMt{=PViKHR0N{2qb~0RCTwG6cZDC_?Ty`>cGIla$WNC9| zbaaGMxWlhG6M(>gfB=C&Fc1Vf2m}EH0)ap%fFPj25J(^p3<78Z3@U;nG;f~935*DQ zN0KXqT*F}KctMizS<>+l>)i)R;dSa#7jcr9M{uUy5UOyJzdrZOx_@*IitsFGC5tx< zl(WQ`1BX91L5G{T$*>v1O%|sHd+}xtKSgm0+MtQQ8?(t|hA*a4bbvh%qK&#Aaoi1pHFf+jqiwg7yLYm#M!%F^Ol5DUjlOhSCtyVstb3-QW zxZ_9Ck@J+tWL0bE7j5p4E+)iiQ`5UdUX(vE2~8p6uxHgE=qQ@_IP(~m01zK~78nfBA*fH#6$81dr z%M20K{2wRmVM#i|TrgeWYJI?xRNbJ9N|e2~{@G9CR#M%o=dJRTinsT;Sf4l@_dy9J z5nG?8pPharVSmCKRlB-KRHB7sg-RmjCx`~`ayDs7cq=s%KNh@vOG&<*R45~0aetIp zMa*f_KpA9|aLJA*dk-FGp@biEa)+gkaY_QMJ28wlYBqZk@+h-H^spo)Nv^fTK20^{ zNW&4p(hkPv=ZdvSJk&^^hLl7_i&pMndL->3qF_vt%zu>oan^^VV;iofLDWsO4<)2X z?q$f%Hpk>Uy^W3ssI4P3Qo+G#$yu*>V!^W8Nmr6TMIeSL}GRV^=+9y>+kaQ*0 zS;Ep4q(GAVS?>%;QsZp7ddoEA7(wd*+K*)1tWQv$aGKT#gTU=zkGaN{kFbU^ozb%n zblH0(1-Rt0_DJDNtxEH6ML&-)rIxyb(q2&OhksP8{1lHE-^|rIOP51;WF1)`VyOUS zn;m)5y1}C0@!}Xnw3v|`#!_kV`uQW5zGBFv_)KYw3LNvz^iz&v}*ICGx(AqSD;AUZae9wO^9 zOeiJtBQHDFOS-T`BK+W@AF;Q< zJt8pEBCY5Grqted6$Pw_*n~rhh~DH7F?z5eHbe-=OzbQ$TyL?mrR7+S1x(5^zJDnl z3=lCB$p4VP$w6YsVZ)oRWr*L|Lk!HX9>Ov$l+_?r-rOPeh~nuGy-6PHkSra^P3TK) z^Y4Oh+e!7j1&73FiG8w(Bu^KyFL*oXzg0rkkV6gGmbQNc^bFz35_JRR6ZkR&SyS{B z8=&-wA$iUu^{-MU84MZIk+tb0NPmzGFIVfPgrE}5E#zBE&=XCl@Hu>4!BK>GnK3Jb zYi=lD-K=@d_vO&%Mj=;bPZaVoF-(gnTWFgQdQ^#&)K135gP9$HO_cC~h=fqX2GttG z_K@ocr- zaMS9R+brGB3|!>W!~yOEImNU2$W?AZApY1E1l->_?J>B6OiIfY)SlaD3qHd`CkS-8 zXBlWTNR}BEyr2ce6E)mD@P7z_%yX~Yj;ngm#p0FQX-8sdeI(YEmvrU(D{X1JL6Ybi zKg&(POV<j_+IRe$;sc-=+A4&!UUG(J5H$sit=v*jy;zU zPHAG#JxpxwpywY~SYES$<$1DSL)g;V(R&PUyK?L)(yfWZjkx$#$$zO<=z*@^KrXaK$E17*Nl1)RhKU7?=w6KI z^?`MUfq^&sz_fSM-+xO49aw=gsv9Bqz{Hw#R0+t+Fq9FrJ{wqnD9jU~yc6LHhii3} zWR}iJxdv)p#^l+hQKe9ij+7W)s>@I5;|f;gi!aPzn3WEq^EqrKNrH#Cwnm2is9A z=aLDDzu%iv`G{X1YI;q}b0n6itp!}2Lzl^QxMTll+V7q5%Jcg+v=T)bGJg0Z&p!J9 z4}CiVLsn>(HGz(qQdHWw{Ca)^`|CuKohp2vQm z1e|UF{ZdUg=zlN1d&YhjdWVo_!6t6XsB%cEP@nF#9gpVe>4U z+5ii7-9Qz8L@D{2l z@(S?94kdZdcXw0jJ4)8NX46RW$eKYbC6P7bU8|3P0e@?oG8{?t=mz3Ekw7|qvs=;h zohk>M@Z{n^Paz+Ff;RK098ML@^c-FM(i?E6nsyR~9>p;O0d!r)#-Bc6 ziQ!&xM-`8oEucEIq0v&Vyphm21q7%dCjE8h=4!jMn;6dHQ)mFeb;1eJb5*qLy&!hP zuRGkjU4M2>!H2<pcZ{Tyxdo!>&Mdq+ZvV#z-;zC}K2>#t zn(g!9_#fV*uaK_F1>$}0zSb*R+}&SYOx^t(L)u^4J(p!xurbvv#b7Hqf#`_sB&c;y zQ-AQOGfLmT&0vNevy$nGOU2%vfaPRpYEt-c?n57P^IBf7qcX85?6zM<>n0Anl`VtGKpbP(T@k4koLH;`C6EV1JsS=%f_(+9O@q4g(h+AUYsNvbidMsXaD@ z@^eM)oeWq&79Q=@!zNv9aKMWh+Rut$PIyibVzPzeoIM+13K&yecACu|@(Mo3E#w0i zpa?5_-i|rhc^TeA&Prx>aNg?N1BeBy6jJ;ghhNPL9j|}S3OqZ3B0d;?Es3iX= zLI5)W5D-8BVdBD)KpYQ7L5>Ff1YiJy0)hwx0|^2J02=`q2n+xe0N?=_03Z+m$N&Hc zAOU~?;AK!K8CprsZUEFYSC2?E-%WewcHM*v*UTI#A{7KyecFAr>;k0K93b1~I!6>; z;1VDZ}$MC@XuwI42$;W^3wFHsLapGRNC6D8x;#o)-9~npZn*XwK*BFv!( z@@@ozaOT8SO?T9SF}p=j?S8`5*^b#kItPArY)u0yPuBprMn0Zm00>^Y`Z9^s zRcXHgw(RjDN)n(187~P|3%Ru?9&%ELxQFg%r1oF#D`UaMB@H#<;w*4EOeld}zyMD) zBtIFo{A&zR~@vH3B^3{i1@NN(BjR^VZol}%r5-@;|QPME3VEvDghE9OWY2vH- zTb4|D0^NFMT?g9O9+o4@9S>RbcgQdT^7xOO@4?h4mCb*l1S;+8JMc09ToC7!wBo8T zYQ=>x$2L*+E%-oi$%_m?w^09(T)uPfUP#emKds;mOIp?-LF`3tuR}r)4V05F%p=8x zR}p6WxFmr(hq^G}*Tfz_zLjGrynzbnVEsszJp&9NHo+oG3i&t_$J34DtxS`vz7glk zgd;A3`p|!DfHpWh7I6hC+amH{@){BMw+C{}=wM~1p~tx98{PMyc;I66Fq0)Uq%33Tr zIQ)+uhJ7Cyx5L7CUPAf@hoxU+uZ-0)ZrVh=*CN8a6RoqTHSL~2GYJ(OZBVKl4AWCH z6Iy?|0f=B04@f}bd8##mg3<7(XtEfB)kTQj_cBmh9|x%YP516HFfI1C(Pe;4%n#jv z%b2i-EKi-B^0i8s)Dm&M0bHy%;DY}UX2L+dgn@)+my0lP?i|-vya5!L7vKW!RCjpq zT7O^_K|MEb7%rZv8|r-D6K2K*#T=3{2bO#5iz9O z8%(Z|aVC_AKvuzjhYFk=AgN$NtsE|;TX5~rP#-Zawa)b8`+uk`gBHuar;2Ltf z{IvGG31?L6-7lM?9D;cxACCltm_FV1E3?0ff?B|gw@YLOW`kzuZEh}tzJGsV-KL@{ zrU3z=%DW>ASOrzlT>;MX$qU_HWrv%`n+p#Rc2Q4W=YKEoQc<{z`TMJGNDb&0i5%(@ z;yo(1mqN*2io9Nm8dL4NmO9DBK&-baf=3QB2l8Vn5nK>T&UlJD`l{5^A$uCfU&Hj1 zQqeem^KNqnPg=2ewFdeDd~wbwmnvsEjs$BmHC{JuXKsGrT+ zUP~OEg=z6u&Bk<|C>~Ons}Lo{nqm)aZw_s1S(z83+Ni+603Zbe;Ejg-fB|3&+K%)b z8K^UWSW~TNo;QRJJTB671 z^}n(X&Q_{0r}(Ur?wb|E(VoH44t)eG6fG?M7cmcoJ*6~~^#7+Y+NS8DT{!w*prUPB zt%@nyZ|wg?>TlNcM z|Nk}qEF5wS!+80RlW4Zg;(->EuKXWs;8K??AoPBir_rKo@;_nnUp&C$b4mUmK=MDR z_R?EI{?}6;PxBuX^P`sCTj=x*oZG)fk3vBIBxhBWfwidTNN6x!z?evi8EN!pBItq%@Vkx<(U^6};3RTP``wniLxp$s*D^UaAlJ=I0 z0ie;CwSqQ+@$x1BkR&b+f9kE#l-7^w5x)oQdB}V;9~gh1vD>UT`>|)JG!>h3BZMHa z22S0eDzC!$qa>Aca|y)BF`F^vUouv3a=JfBYaaQWQX(5s`0IAuX2Uiz?XjN%n{+X^6O>%>g^EDVj zF@t{*Z45^KFBp+&!3cmW7=fo?1nvYQ*bzf)VToM({c?g4w_bnuX;qNlj)_ zZ~B}+LBbcxG%Wy-npn<9n)0OYMbirSQ0R{Y+yK`CA&s9ED9E`+RqPZ8(uYF6hO#lk z!hpsb{6N>@M96pkD9rUiemNfGTW8opPCtJ-0;*?ZTPt{nb{$MCiDY?kMeQbr9;Sa8 zzRNzsR>@K3!eUZ|G9Aa&w~i3{;iAYH-o4^CT&R7n#_Z1FubVGNdz|z9>j5OB&a1-v z5tvoQC#q$OjQ(5Df+!Qp?9;(x>e&_{Yg-V^fIUkAQnoeF6uj=#Hqs>@Aw+FyfJT3d zuxUFxLB$3Ogl;a-^D}K75pW3H6qTW|BtOXo#aWUX^XPi1I&g~%R*<8frqTH2q1|4R%8Q< z8x}MO^q%+ALm2kX{%O&F{rAM5d9&vjRrIFf~XQ%&-pOm4^7)W12#6 zb2Q&>w~%_GA@HeVv=8B_$cXd-p3WTl_-dXt6Kz#tYhlq<~GvIh&n%~$^uk#p)(+CRhClCYN zjvfBw*`}+eLFp~w>cnRBFYfHvR&4FqY&|v4G`3~X2Lj@8Y^I7m37^J>qpgnB7LKi= zs`~~QdmsQtzz~>lGNjjL9WQ_X2VE|D-4d&O@Jqmf&9IIb9Uk{D0muPvOgJW_8Th_{ zN=RI!&Bmr@%m8Z9#WwZ33&gEU_kd%cdlt#TmPif{n2EEa-`F$QS#*_Y+Sl47TpLB* z!B2keL3VLESx=*8R>GteSsk4wrjta8n%~9f4$w7@2Uv!Fvlzv}lrVqAQ{Ff$uDr1p zZpjoOTbr^v(~0Hl(wIkts#&(B*=9J##ne+6UY#tB%#HvXBKhJL3Ws&PoKHA%=oZj~ zLkI^B6OOsLrAaux(N!cIk_Gm`2uDcL;E5FB*n?<5FdN;bHwecb@IAt&G0JUed~g_X zoWvJd;I2~#2Wm0^0tbJGh6EUJgF{Dsyjg>TQ-ecg<6ZNw6dbx9DB?CbGSb=L7B^z)%L3QxO)^{Fkdc?x7}_n zINHGCtjD$+jaFFz#qox6ZksweL8q78Q`uU3x(x|j0Zsut$O0Xra)qBbG&TfC4Cpln%@kXDYVC?_%26pf?Pew~)l&U0PFUPR%P`e0kg4Vmy zSt}`hPbY5TG68>lf`KZFcuL=lIoVClJexo$W%8dJdlsXk0Vd)rr^UCJgQ>H7orX)h9oMPsS&-_*2%@TpZR=aj?k6URw+5Uhk_Tzaawv zGk!;nt;nWZ8P$bP(}fo@(Du)TPg7DR6$_t~chs0a419kMxGb$GbeesOL-O^DqSM8{ zPs_X%NB3*D+J@9_b^2-wS{R@|JiyL12K&|+vc@v)x`3Xy8-QTB6yN0 z(fLY>FqMBSIhInYUu_xZaAqfnvX~FCWh5||#5|2Cf+LTf^Wh4;1_DgDqI&|}fh*kXhw@v|XJ&Bu zR=99S6y9N_CvapxC~z{*;kgx8ai-h}Xx8B3m9%@s68#}`@%p~*b%1Ny$XZ4k|KRSh zS3SQSc&%0de$qlg&{denCTa<=jX!OZ$y9%t1>!(cU6QE&lVTC0RyYX|oE_Yg=6Md) z$lB^}->rv#@A*lrfYEK9$iwT#8iCT*o{FdAXA66$Pw0g_sdct zSvDJ%ii`MaUE+6ED%y(4H>E=M0eZ%&T}>*me{dvLvuhoIFkl<>NGgU`5-(CQKWTsH z&!~<^?r`DtogNkPUv4wD8?5f?M#Y}BFhfJI)yEJ|I*;+&etl*y?W03y8$!P!`W?@% zPPk;~v@@7KwWb|*w%4RFqI9MZif@@iEB-WivWS?!&Dq(`;|F-6J{bJuuNKbIid`XW zfkX`Ql|dz*=&-h%Ed*tazZLH6`b2+Yk+QD~f>h5O;`eJ5ISI_qHla6EA}9>G!TLav zgJBd03f*&{ofSP1e24@RiSb}0VhR+aBBsA`;x&Qd$?T8;v;}=Bex@)8+90UbUfk4P zuyMnr;e!FE_VL?+QeK!?{g*PQa;tA!FZi?jSOft02brm%T*rOV)Hi>-+7LJ8 z_PixyM!C>}-!zd;^{Edne;>VS&lbYtxWwUVlxmwo(7eW&sp|{E|BtQjy!mmG8TNO0 zaJi;^AGRe1RKmGAm66$9dxDgZ8WYq5_`;N}{8ktfGGZ~sl_hVN?uEoh#-Dw)8N}*- zAbv2vjCl@D^#nX=3Z(N8$+UlFj!!)`sV0lZsv1E=oAb;msXrdV-Q6w>e)?Sn(9ct? zxpW+P3O{6A4oka(K+@}H<75|{V%DLl5!U<&&^=E*#4`utm!AcvMg^j-<_(bQ*D=v7 z+UpEcK-6O;M?tQ6M;+6=7Sr7FJVr3hTPw)SaRkaWABViy0wwNj?wo&UKi>XH5Sgs^ z&!5CO2_v$B(3X~9F0j-=5dNt}yvr1|P22;PlbG=D3yqT)pMOST!9M;mVtX+2rb0B6 z(m+VcdCY8Rp^waQT!v;ddeFNv&qFn%NQw$gXFOcmNT4g#7yFkbJl8Cv^PsbN+=b}w zJocM#gqsbD z$YwwkYx8J~K#+_ZV+7ALf?&QF(S6V^N{w_rwM7-Eb(Aj}W;&UOtXI1;;|Ja2lA6em z7G{-{wm`W2J8S6+t&jxY%jfYo=p)X@E|TG`2u-s)jh$gEsPceqS!J|>tBpw zj7E9NpbCZ8CSHFfChaZ@4{Ni)C~Jq3u4f;vFeesr83bW{a{9DfPLcHrT!~REFCQ0~ z;%d7OuN!2(tjEl0HK>|>_oe!{rAyc$hGSfAzA>)7+H_ehnOlts*)<+eKk+OdLx&^B zr0gZ0GbfIT`aeN)63+{q-Uh27c>d-y4FLK-;Dw$L0vdm~;qVaxZD0oC4S9RL;0CbB z_dg#EeLbwKj-SndwJambcC|+sz8{L!-euu?+_rWBqx$aV`8W!X_{hh{I>Mhrve%sa z!GD@~VhN**uFl}M-*}n%{rK^UE%*r>dC6abADi(6(@rK`&cH8%l~)l$;6%?8cp@zN z7C>-*|VZmlc}v+JJ3mR=K0CW=B_D;l7Ec%r0svz#_?ES{%;-g4|h&`je*9v9~_u` zy_LH^!vK))W6{ybhElV8=}yR=WNH3~UM|=@Por-&v}LS&0hU79yy~8p{0ISLxSHwt zlNg}8cmTVf^lv{2EOnx@?&T(MP=}wSl{-K21^_zSCq2+0wpI=a;%Pn^CxKiMUqHSY zpBR6h(z;3;AMvN^hI`UA@=?C*gwNYH_GB0BoV~3lTbglPRX{p4kwa!d6;@86QF3CA z)}2Q`*mJzZ`8l~2?x3X8aspUDsgRd)qCWr5` ze7m?KZHqge)6Xvk!r#x7vj>L}1y-Aenl;?oPd7|Vam6|b8hpTGRf95?aCIxXe811u;Y<;XeGXk zWd2IhFerAoAiN*yNv26h?1%!*v`Hgkhl=Sju4&CHrIw)i**)S30}aNe&97s1qSNaF z(&Q}iDF}GL82I_~9{_{qrDmR~y19guxGWwhw2NrLeTWzjo!{w5Jaoly;Zc7Cb^vao z_s|UX5(;Hj)ir^Rkj?nv%G`u`bpSCqJunwNu+|JAo?uQjX|jVqQE-C?wuB7iw^HZ5 zvKQ#7?7$qMaO=PjYl$0IHqaFP(q5%hhUEz#2X4$kI`9g_SGzU z*JaiFc2!PIV`G}pu|WXu0`q^tWe8#Vkd40XP^6=3pUN~cwM!uNjnNnNjZ{lp>*AiG zv0{zO3Epe~Zo1n51YV*8Njco_^tEV1^#9NC^Z!3q)keX~NDBz_zgJY|f3yv50kZ#q zY9N}v_J5Q8aY}no5ljF2G=Cq^xsVy~_MB*23^~2dFs@;_M@g!q4c31{0||6Hxv9?* zrtWmO?xFslk$>@-_0heEfkwr#(&;6_NYzqnY6x_-CBRn_u6`0mBw;PyeJaiON`=j`MZi?7j%~{MtOCUsJ+R{YW+8q z=ZoCNqrjs)Vr`1spc#m>SV?BQg9Na(mfhmzOFq;gy5k0gh8fZA9kx#oC{2lnZYHiW zQ50u$pe*JWjK$O%6=H7aB{r4)+A^q17Re?Qy&qbinHnjZ!wi3%k^q3Nz!}U{lzQ&c z<1o=}Jg|L)%@m^;^%AJUnoZS22@{ zEODFrNYfDeA2WZQ*rUoL*GSvi*(|+pHmnOP^KO?@x2-BvBu&hkFC*$*5!oE5Nq)}$ zFvK7@rUJtkQg&X0Teb_X|HlbRQ_wN#R2W9FUFqAXbs5Lt4iBzfcO{gqUGUCAi@}sp z#M)JWwM(lii>qDSc7QH8E&RaW_y9#P&m7ung>*d>CFp;-X_r567715iqP^8w4tqJl z9>NR}1g1mc1J}5VlP~|Vh!dlCV{yc2pn`sSzy0z@y8f}!o$?5$QH^zyQ)LS@l16-o zm%!!f#?=JiD&aTa1Hzziu%6&?F04XPRX_e-3Q~72@i4O0D!HVqEJOXyKlk!nx*Z*% zh`#J$7vO)A1mPlZK|h+^kbU%OEedP&KeLJV}eMaMJT>ueKg;>_;t3;0ImbHN&UfA4}BmM@QH}zR7=qUcKEy7 zXg*x;wxhKjmYO-wIIi5vFw#XS8@ha5ST(R!|sdA{X)zV!k zXPJMoCBbX?IzzEmD>@N*b=cA@JrIX2tv75Td_$gLE5rfoajsQi0ZXOh--WHPg{{{K zwaKRloMDb~!jPkRZ!U|lWrwh(g0KaDu=Uqb9_n+j)$ImbbIWG5{x2MpB57fqAe3H| zgysxvF@-zZU=;~<3g%dJC!z{?w-Q6R;gx^)WL!299!^rawWQ|`I5di*=`Du77N*sz z^0ltjs*T@B757@s|GOq!FiQVB*hF#AKoFJ<=fwD~GCBbff5k9fi}L*=UTZ6|?HiD> zJadx{1Bf|{e1thryPG=8q<>&tyVl12Th#~yXtxt4SfvD-ZrZ=AtTj4$i>}4`aY=t< zR4IrIri60(bT#-2h|$J{8AX|90_j?S0AfI$za@o!f`_|W=-jWJr*6$|$@zdZwLI$Unrb1q@KYI}}dARB&j3k>9j{Ko_kRxBI}>+NDb1`%kO2zC*c_I2C5~ z#1{TIJtX1}-2oKg$W{Eantv=Pu`mdHof3@B| zEHUfVqAbMpT`dxtoIhPH7K}VstCkPQk}@7o=X;z&f0JzAVLA#5qROXUUJ(x^MVmB# z&6e_Yf)BClU z{{6~eo`u2KUWc0UA79Dd6Z3f~lw?IgaaNFgLd5@OMLl#Y;ui`;`RHgsapk|8yA{aF zkB>RF0+e7qzSaS3c#uxZ#SwZ6%FDHXNr_*{kOlOD+ORUfkgsvQI(|il?0bJ=$LGwX zpx@&&cBqDq_AlUY<2Jxw$yzB#eEQoFA1LiKBn}@%LYvk1asS>QW#E+$A9E&`Z|ju! zvdAj&{W2hpaffdfyS}u%;LGF>9}+Xi(%-2|sl9pd22arSg7EV9XeUai&4op#;_6$BhbHuom0O-=)`R?DzfPjBB7dnI=H zJXq#mYosy5WS8bS|3Rn=yrxXT&Mv^rlpVHTFmVsu8)L3a4>atSjq-r`AQlsX2k+HK zeHq-rpcjRH(d(dqA2c=J^ucKqodXP8;!%|7c9#aR9UfJB*D4EsDg_jObu#C|lKu@I z{k^bL;T)Ly*LgUtB$eZJ77=dBsU`F#l~~hxp$MZ`K+72!al?VJloj>3DT0c?USt&+ zsaW_h}ZvwPu zgcA~9!cZbNf_>4TxU^n>bp?fejJ7$iO z86J8X-?N@z32OjRQVr^700?KyU8%(j0ei2QoYsk|2R1l+467c0Zx>DVh!e+9a~TPs zl&wl7qp)|mVU$k963nUSIG8F71`n2oZAXyF@zMeTbksW!pk){%?$S}O9yr73z#7^~ zjCu{qtHv5mSQZ%VpKX?o%3TinyyM{a6mG|Qn1)-^0QKluj`WwVBY$KesMiK03VeFq z=tw})>$yh#5Yp>^=Np$wJUuT7#*saGtw8tX&J;y(>9ugPQDQHPKMG3j(Q1oG$?xA+P02 zOvlJ_i!6KwUqY0Avh>H`R0nxkYL99-PkI#vRN;6AdF)Ytq!gJAy(MuD0)NJI_KcR$mM{h)KLzKmWTU)R!``Rhg%_m@ zyr^^E=c!NT`RIs`T~vHs)X5*OSX~qhsQW$srm&Zd#AN52$^mWFRLXx(HL^jY;6o3% z?((K?SK)bo-nAR=V_b3N8MGS(GMI&D6P_mUUYtZ#(s89?l-%f#gEG+mF?p*qtr)2V zr}+#~{M)Z_*{kZb$^3Q1%2gzII$~YS5e6NxiYem991;J^bw`dEEME2bG-yZ(0zQMI zD~RpbU9kkknw+FTR*jl$!)lb?MuZ*30A14=t!!u$CHd+9M`dMagGh&7= z(~Ov^`i+h>d*VUZ%q_c_GGmtU&O2^Ii!|0z?O8kfsHHK9f#A|5u z45Au;qF_Yt7kT57c%yjS*FdUY#Pt&qFR~lQo_cCzk*-6h06e8-^-Su;@xXaG>2>5EMYV?a908d-C`kpS;0ik0XO@GhmNH zK!JEFnKzWOfdjozi&`6V{usY3;BPJxj8BYB4Irflw}gWOF*+)V)7O{aRh zsp58%)OORuc9S8j-6Te4`=KF=y#@`s;J+rg{}mBFGz{SpP_9A_tFx z?Y)=^fB(lPG&`Sj?xM&Vc(^u%Mq*7E)NN3s|Co{E_T(gg(6J_7^WFFV7yvAPiwI&) z3pY)8QA2S%euDkR?{F}jtN84n3Pq4WoAX!hgHmx-Mj{MODJk{q*efv_`3`jTMk_y) zK0nkAFpV0OEUe@`I70i@{Vb^dNexP7J1ocVj}#e)4%rXd6|o%{Jn}KTXSLZYTJQEk zHWZKh!?gkgEy{mcD~Wv2_E|}P(wM}+FQH(|iD9EWQxe5gHt0C$z%r>?B=R2u3yuH( zBPvnw)ezvw0!u(sBc7$N*4L>K9;|ih&D@7I{L531-jjE{G{Q;A zyD!9~`6SuEt6iw3_c-p2>^-;mCK`2a{YW55RtdrKGW0MOt}FU-gp0F(g}Kr5?n5QJ zzMpVVHDmw5xP z`cpyBe2F^A7O)@Pp^fZ+2cX`~fm_@bNIY4FH(oX1YVxk{c+t^-(NDJStKtLqE%YEt z7lm#mH6*1<6RSoD-kqy_sAU3a_GeKU;^xvYl|3EE+aeDV{o0~*pKP1(O2@VtzPs$o z^>azKum0GE{0;mc#@CT#!iMI$ZhL2MgLHE*|FUHEI|lV$^<^8GJ%OZ0?Z1)c}*fV`JKaIq!CVCHCI!cm{0^MNMF_gKTMVqytc8wZK3%thyXrM1GT*KlfduEQ({w z5jFQXX8Iu&8bZ89tY}w=WMrbs^R{IkhPdm1D0aZs?=P_e8sJlRyua{v#zCBteo!mP z-VTv6s2772(6!z~=`r&iL&X`69lK*7KdC!H-P`>bkV8IysKaAZVye?A1s3kW;cJ%@ zjjwM%bs_5>RR_1NeLgfsK5T(Etw*&#DSUkB%M$! zEN?_nn63za`B3NSJg`_*_W1Ydr9W|x=Q0iYi7;xYi4<&L7L~lu+nXFUAM3*Qzek%} zH%j5GrJ9eEi%BjF-B6sEcyp@!4^wBPIPfJUpFL z8`s^l?KTZwT#YD<1hs30e34^m9LJk>(7KO&*oyJ2LzPz6L3b#fm($WtO*bN039IsA zHBZ__916hGly=AVwneaV2N0CVQ!gGBf~n{3ru)R^0ZIMh=LxDeL~<*B%m+uZ+!{xY z?YujG^uTL_5zH7E^QS^xPl~FRJ?Skp$O&KvN#H^O1TOGbXx(Nm~TF9-F&= zP-~Bna<{>tH$4wM*A4FSJWUCKBHa2A0)>Y(XwmZHIoa3)lGM59%x{$kRDXx?_hVV2 z&sPNG_`1xsnc`>(e-i3$X|ExJ~{{QAlbM%ELzY3og+H4k8_KEsfkv1Z5a@wV8Ii#Dikd+O(UcggA&B0rL{@dg|s)1jOF(kh2ry`dao++-}HGb920PA%qSd^K) zjT|q>bluqEcD3$#Ks#YPrEgN!O*8=Ix&bs*p7afBLbha=YL}Z~P80%st;@iRkRdD+ z@l+hCY0n(ht*7>Ar2ql`J(%@%7)%cs?5K40P+tR;#DXul0_M>6}o*zsD_q;Qu zp+5x=AHMfUonn(*-J(iSFyC;fHm`F_W0UhP9#Vw{0m8&>8M?Z46Czk;S2wDD01_JD znNoqJbj}B6tHf%yYaQ5sSY46W?ei4j7KnHV#_y!C;_zXF9)N}f<>p78tqHXzDc^xX zT2q%IeNZU&fjozqbn6Hmu-~_h1%@X-kLGmX(4gF#t`5QqNUvnUNA_Ty)4EvaE}pD& z5uJrYTe!0G5Js~5T8Co~tJ74RtseXd*{~uzbflPQAc8ptB8)(PpTQYH5{lC`j+^5| zidP6?{mN4$V?y~A&~K%_Ff_Qz&R|A2{kD1F*Xi+Q3gXD0o8gJ(8dlfR%o8A z)r>I29RFOGTEXAL99;7V5yPDMpCq9$CsvMMsXATXf7Xeg4$V(}~v1N|c)Fd!f(5C{l@fuJEE2m}IxU`HSj3K<3B5Hs9 zProS94cMde<&{a4v5meo&t1{~UA3%q&f9>J<;B#rKBpO)&K(;~q&{!}$m<8w;*ZDa z!KF4>x3wJ17r6%MiO(kcZ8iN0=uptU#U_9kh&Xyz1HZgO8cDRk!m@uC-p~$|n}yj+ z+IZ}ea^nG(wvZ~w4A{;1a2O+)8?0-5~H14@9;Q_KJP}JlIMT7rTnC9JK0b7?lA`}F}h1n9SK&KCQs70^G zXX+iyNP(ngWXt^072JQ=oXEFsP3$s^m*oK0V9KYX{ldi z1P0X;3y}G69u$>=lTc0xo^f*(#eXt1)L+;d-H4v~(v__denQXW{R`JX5zsS*2a;XG z<-!^tWhfP#XFy-I$wpxCoO$MZ1$*D<0+?rV@}T<$+LdQYZ_Ix-Apuep2X$+WJi`}+ zMdFzTEZj*$BWKl8kqls5QclV6y8ZTh9E-G_U;4%(8ogzXV{u;B>B-%(7msb$3}M$M7N(nU{{6w1lE913llPP@G57j>gqB!myAs~P$YpxBp1U%9Gf3f>VpV+P9VvhUDhlEH+W12W+ zfdG=C5~+UyBzeS7@y={wyJd3M**FfJ0Fj4f8V)f2ezp_7{5!6=MBvAKLRpt z5^)AumAD)TX(cWtcAPXOq!QUE&%~grj{Bz!{stG%%mhm8!bvPhk4GP5R!(_A=o2WCY~ zz^FUoj7(lCJ*sJ98>Fi?&XnFL>tFvp=!EXZ|wKbigLrl`u;T*(aH zs+)iJ!2HK>Ci$0=^S=;V6(2|{yHFK#=EW84{-R|3H|nh*NKzdk-Lt>Vk@G*hRboXIO~EavGngEqbj+8C&uWrxI;XF*&fjr#Hv5wya2iT>8O;y{M) zR_yd$S$G*eD7q^Gjuu9^VDpiE-h_Z&ImA+bE0OTzq>!2afPJCic>(?UBG()r6)ux} zzvF{HxsJb9o)P=RhM`Xq5c$j3SXY0deTij$5+|T~$!>`17$*-eIYfwO!qk3Bru3-V zl$R5jy3UBt^>WG;6pM+Wu-K?D|tI^6apQBh*rHNLhTrZ;Qna+Q!lKgZuefk|(2+%)kW zGri!nqDHk0s7(L_EO0u~! z^H>)?koVJtvnttrP?@mh0i-=-iUgl5NHFnvSgbG9`yX9#-ZaPPNj!hbLllv8h z$4DWO80O(`74xu6Ip$$fNamp#5(k13CVC$Y^w4XKpKPSO?g1wJdQ{66hWJJDt!gCV zyIvJP0Y|6{uNBL0-Cx8~UP2a8w6Gy~hY=dyq3QT<)bhnH*~sh(sKihnp@FD?T(1Q| zAlDj!)l6`n(g-WaP7;5)Ai12kFWm>d$`D5;M?YiMW_OEIT4ZTT3kyu`&x)ydK1yl9 z<=+AF*5N?-Uf5-^gn+~2l;ENM7~zMY3@PFfu2j|3JnXSYoYv_!@L%TY>$&*DA{pe>vi14 z-l(sfLwzOtZ`$@N=KC3arC+52es;*>83qi&`~ND2=m$Pj1$K!Ar!B5OQB;`i%W&Mn z1VVXysu>p46@Qvxz$2H9i4wMo_;7}b_8Zab`(`MaVxE7T9}(LP#*ziy5f5j*HiaeO z+x%5DV?$E)XHOUVTmhiZ6*<6N*P1H}=pxYpiH3(8e>^=WO0=PXt>rH?y4aSvQ--bh z6Jp9x02TG544q7^B=(Y2JOM|J^tsX$3fMUutuFdw!G#PUQ+#1ffk^I$yH|IQH)Gwm zTg90*VnKfpR4ov`osxKv+hM3XF)W<)m0%1-p=i|VpY8VNCu}hclm58EpX*lfm2Ev{ zm;9F4dF}unU6^{luZrv&Zdj~y({el_+PpzA4M<^z1S;<`QdcEXhMkFzfuToe2C0ma z*Qx2Hu!PQ7;JgO%Qbz_Az{Xz26teKfOCX=k$bo;MScTz{L8IB$_6K_VJ7*nt1o%w( z?`<$Mik{MlPVdNOluXJocK|8cYzt|IUihmQbjYWFIuhp#wHI1;rYU82) zx%#HmH$|yW{iMF2NqyOp`o1LfL4Tya%SGz*hSXOCsjp2x>LXW=`h*?z={f3)Zq%36 zsPBJkMt#5-^&Kzj^UPNT5{M%SQV(Ov3sBro&gqL?yHB%R9lr>6VEQ(H=@a|Xr@>E~ z=B01wkzhEf4O0@p5d6?ke&Hg>N)#IHypS4u77^vE3w?nJ8U*PEF;Nu3-Lg<yEfM$O(XH->rP`8pZ9r;AC6P!uGl|^oZLxb~r zZ-No&ogGz1uiLKgL2}q=?392>Nbk+w?04kRs-bOnC?maP?Iqs}#(=_^wS_C-uTz+Tk&+knP0BCV^dd;=vNs=KX&% zlUGM?I3G;P+i~{EV)rI_ES)(0D0J8a3VI?xYAC-N+mHdh37*K3 z)SnK!UBdE@{i^FJh6dp`5>MAdm%ef17kbTgDO&LRy8{%``fPj%)O|2xS7-E_lhMnA z4Jp)lcufk0XzY&KOo3CV5;=nX-9vx%*o#6&be~Kl^~7ltz5aw#-k-4Y({s=hK4a3P zoZD!jLU?e6LPmfl3@QxIjvKO0_@3?~k}|aDs>DqH&n!%3!h=>8axdZ0(O1ujgtv#4 zTq5CVFP7p?2~UcUO?Z75VcL7v%T|Pq-OH~BTOzFCSr+>KQyJ;q3Y3T1`HO#d=oin$ zFJ5I|yl$l1q?ccf)C?&Y=*9C*7E9 zPCOrHBG4ZGAM#WrMp4>6$~}oGbEka`9l(WC+>=7w6R_DpQl#U-7n1v#GPH#4K}wsz zCN46Nl!Ap|3yO%jV~`(cC+UAC5MI5~3==byGZLG@eF|?#ZMrGE6;?}^L(7V4f#yw1 zxanx29j|{95+MqF!7d5k4Bahb60(B^675978&ForB}9mnRZ4u@v{^X$EuB}%fN(ni z)T}Z+(W#T$bKWl(JE*HEZaJi0atUnVypxe?shG%uc;;O=p<^LxYsY_zesV1D601`3 z4V`3G%W;OGN`WS5NE==F-AW%Yf!$AnfRlx16+frh--C<*??Pr~(tC34>ElHwL{;@z~ra=*fK)CjVr}S=<0r}b=d_9vVC+I}0)8v2rgA4Ojz@ni`B1+X> z6IB8OE_;ylp!{Ub1v=BmNj&&_g1g&qN23R6XblG?W~c_im4jzJlgPvQ%k5*GIoYQ9 zyx2rIZykKZZNW}NXq7DQ2(Qs05Fyg(fx5x+i6(HWHb`JcA6Td%C>#vDNrJ95D0*^H zVsV`WiA+O-B*A}8^!Z{?O_t!#vq40S*L8&58AMmj)y*~5%;130lD%3!(ogy2RmBgX z0v%`{^mCHu=QIsZL6L0@wn1KJI@yuyjPfSiN|@vuqsE3h$s7vTQ5`^szjWQf717~*S$GHwl^mU{(5th zk4-^kwPo!*O-l$ASO$eVNF*&x2!Vzyg(PT1Jvu%=IHiM-q(EyvOD5uc0G|9iHEd@1 z*Ui*7kOZ^N%N;BE=;haVl>m|~5E6jK)jtK)AIa+VmxD#+T+1;pQMsJRs|b}->O;If z5{VyIM!0{%qCIk0@{v$m60{=>A4%X&xt&;P>Gz2N3-UXRAy4>QBG zVSZf}3@#+KiiZUoHd9g)Mw-r-Z32^zM&& zCK+B-^`pS!xJP};0rI#bXz*LKc-+&47zYQNh7f;PSa)iYxefa0We@K-MC$*6zT9yF zmpTt`tiqQ;`@aW2m*=3((c3AKf3I1&J`vUeVjmBgyIvY$1a zuKpV$m10*h!V?d12tP)`C5Ur9(9HFVjOrHw)K;Tkq)Raw3r$u_TQb{5uu#Ef`}kxP zTO;`FuMw={G8bA!@T5hD<59=@)`Ur&U9Nvq0V{$i-6ZNl1XBxU^se5;)8git8JW~> zh9E-kSM5Uxw$lieiXuoi7&b8}{-Cj4U4&rKueEV==6B?Equ&Eoct!aa3^Kk{hEFN?dZ>pOC|+o?{7)m5K|RHs3qc_Cv&sjNQme{HryS2&xvi4o?}82O^yOB?C~g zp?>A1-hTn>jolAn@H;mVO2Qauaj1W8CSdMRD7g)3ddOk2ZAjm^(N^5-I;OWHihvcD%nH;u6Q&9%MU1PBFCFI35`V%P&TKqL(Bav#bByZ4ccc9LUDhSh-KS6 zR8aYqgB@??Br-Q!G>I>u^vc%nNaA6ON>0hcwJQ42# z``tM)#g(qvZoeNuDjfkW1yxG7(Txfeh!lPvessW%hF^c#Fbx8nA`Rh~z1-y!S)I zhTEFg>iw(bkXX(kbE`9>0GiI!Gj|J8orl9MF`&HPs#M%bVEeqnjm)S4oveUQBoJlL##?Z_r}=W6zD~ zj!io;i$x5yGmax@K6iMGslIl?R4Lph?$}3QNq7=uaPHU|i()UDv40<0z+{X!aR^BO z0Q1v@j~E{VOi}=-Wn?o}F)}l9Z*xvXZgXj5Z*qTRWpsmm{F{Y@@#JspD;STYiVUt2 zrs9Mud7}Lz&sdT@@h-)auH5h9QTNL=$Y*;%6K)Y=ywZvEG!*GiJn$L&x5d-_Q4{pl z;wiWe$&VHfL}>BwXYqVDd$V|cI!ks8^P|dpi80D>Mi8&tQUaschVI|oi2km(^* zK4M9h$OcD##4>yCc1mca|!C z^bX2{pxPLsy<~nZykofPf!=xDjXdQ8@z%$G6N4dEF6%fIJRBwOjz;uWw!DM>hk#h7 z`5Gwis0w(0Ggf8&Ll7-;rg$eHCpIHHAl@pc-@Q#1#$N>3^ z%>zl&_h=kt&NP#2kMrSUk!15Rg2w*!v|&gByw zk#5!q;^q?1v(TQTSiAq&%qxwr~Do6dN^n0^qCa zZs|=XUXEn2i7qU%vfSs~pu|#PRPKBlQ*_8Oy;Wr1_#q2c!v7hl1U=&$u&Glmn4sNyeQ(nBD@R`qrQl zqkxI|V29OsF`!D@fTT|mEiOxB>!Mgs#BhPtmat)w!irY7Gqz>jDdhdcozSDykquJ1 zkS278OnqFw-<@Nu;7)%G?4&RRcQAMY#@u@!w7;E)fW0mVa#$sLotsznu_+3LaY_JT zSF{4Y(jK16b)*~I3g*ym8eBj?C!ARaIzk0!qC`lS&b6Qo4LL0)BtaV=iJI{DVVN@& zq}q)vYN#_zpAd4mpFCO_-G5m3uE3K|hQ<_xZbx5Z#piYqqQ`$hfOph!VZkfCS4SG~ zb97)MFm7j4c;i&Sfis?amimu_K-f5*P=QW$FtC|8k~>~N_v=T{;F_KKZ{hgi~&+GsEJ zA+ZFKB|Fg!bX4sw+O0bR?Ww)9qrh^KAk>~xri)C6aTWTXz72|eC-i6ME(`+IdqttK;AzZevG+2XLh7w6a-O6DuqiH@!ycCAoc?jO=1@l60#5_t zO@gY9??i}9A#*(JWJ=maEH_*Z3y@F|Z$?^Xs<{6^(PBA3o_9(xY|dS*w#5Lf8QPm* zqy)sk3l)EaugZ-XWX3UdwhCIM)O@&x;!s92^Bgk_g$p>00|N2vtTPAlZ&|^tG=U-J z`RRE`T=ZDTp;smOwusw9;98Jl0XNJuY%a+8#b1yWw=at9fW>fe8}?pS9Mr_h*f&sz zvT>kkjWXr?uveqoV~T=+6FiPEhr@eecD>%wIGumugn(}6$4?X-$zLLXMBtw+m5uZq zs}?B0h%U!Du5;DIkvYa?jPb~SbWxE26J2dy?P9~6Fg3ji8dSTwe(IMBq&lK(>Sl7) z4A#CI$vM{NiWKMB6qI~LR17n8D^dtZJq=+z_)Q~(RnJK|0II-#`P9wMa)a>Pr}!Ur zh~$4hJA&~#?i0WB^qz2^li)s;Ebs8YyJe;TyWg0Ol=_Q>QeNpo9RTe&0@uszH)w7( zDP6xoc)8$QF(Auh>moSi6AvAIUAD^&jt$y31*8ANb22H{1_z3bA#F+ULil&G$D218 zO5TSuc6OgfNYHDO=Y+W?PqnBhQa%tBj}?DWg82)nmLh>zsyZQpC0uljvl`N>h0?AY0T7;VqvO^nAI`ggpM?ReAccw?vIO+LpPE5{o& zjyHBV-n4JL$=!IPZ{rQl#v8k9yeZXq6Q=RTqwxkmoW@g}1dZ*&%KKrG(aE8bKRw~9ABZ5B~p5I%ZA%DxkC z7z$5%qLTJw%P9~&Vi(mJSy5A1&!wa+)Z>ei(yt63_hMLa`u3-L7#?Z^qp*@8b%Wuk zNG0|-NdF0@>Sz_aDv&D-FBS;|%kY0J$bJ=^3pJDRAkSoUs?hQ-4lFfz@;Cjz{&d-+ zaY6yBmiRNyNJC^^M=mP>!;hMSRAj|OH^%uVW1Q&pf+G@sv*3Z3T@MP=x;Q7>pALj` zI|R((D>x6GG&JQFrGTM{6{o3Jakk9`mLr3YM_UyrKO_fdc~aZ6JfJvnawdP=O&0s> zQywc0fd}jtd;v(W!#HqymIcB}TMTX9Sjd*7c`M8eV^!V^BV_Ijqj=wVY(B#{c)rBF zDK0G2d%s}R18BOB$m@|%mD$(sBe{lAld<2b=Q}qInCr}G1JZYw3dVvp$`ChALSW&NWkvKKKK` z%Nf`Hpytcl>s@I*HQZwHi=648LQk{Q{nF~5jMp&2(za4|XkuN}VqAaN+P|Gco)dA4 z|06VfE9+I!eQdfxEX_FvseB(ls2N$*{ETy)HLahL4#uOJ3?>DSCWV;4O#8uz92v-g zba`7YT+ur~*+-o22Q*Xw6ar*nc}Qt#O;1!+S4BuhQd2=^VRdA3Z)|C9b8vNRWpq-5 zef*`@htLBRWec6}x&D8=3=qkqPR5$p@XDabBN;FqQKe0}K^|S_N|=Ab&Rm+_(&~7$ z=mCaMksFUr8;@K7Ap0ma9{oz=(exsoj2VxBDC5yzuVBE4@d$vy2gc!F-P@Qjk zxQEW>-{)j+-p|Q=T(IFS`x^Sr-t7+4Soay96xR*;+@Zw0y0h}Hg z;0W0KXy^N%7`cDPI9HT`ftj;&$Txm*uH?qBFjm>Fz&c_nAAfzDB6K6lCyD6iM)Vhv z!&(N=Wi-DyzqL%=*ifEraoumuMl>)b8kElS;GJ^QH2&9etT^ZO0yz)3X(5*m8<7X? zANOQ`&~qOHltff`a_J7zxlEnT)O9)c)(E1V)-jo7&<}sX7h?F<*6(7+N09JG!FL>o zpc3ON=*FI5xTv6+oI+D%=pg}u{K9X5VXgKk?yK=-?(-6Qf z6N{Zb<^O+1jH1#lt0nvbnA35I7X_U;#aWHPP;kty18LWAp>*MOe;+OCl$lOQ67KAx zHNEf(N7etgau~chd#+TnI%SxmL4bccJ%B$X7TCf6{dPjU*Ml$`+Yf3*mA@psrWK8u zL_m;`F6-_h+g$U7<(SMdU|7zRkj(Sp*!fjPUpard_16Uz@poD012{PQe-#$`DlA!a z(VI$%1b^W{t-arAttjQ>D7&_eqARxG3=tP`a=CcgKN0mqEzv=SQUD~TjS>F(Hhn2q z6i)@PSV;lHXkMSMW2V4=oLM!&LLT=G)PRQ#8qwJcqa<1O3A0{@Ip{)kr%u(uTJ zOX+{;7%P0=VPQN@6yTAKyZw71ajoyGyiqu>r~*@&g$0x7DMv0Tf#1zMi(O&+fTqAf zi>_H?oTX=lTa+yD00({pU%=?Ur=V$Y{kBC9^}I$$-Yr?dr0hj$AakQ2X?pHO<1j-Q zV?^%OSRF6{$TYorCXfRlO+*u#h_VNflQn;Ds<-dIGsOZp{#O9!SuSrCz%$qaZl<8i zcFMQ&d$*Hs)IKlIKUYLSkb?CIPAslu9a;piFOR#e{~v>z^WJEw&o?{YT)muvuT zhi8ul;Co1{aXB02nFd#iF9>tC822+(IV+zCnMt z;W_zL1Q!5a;tdl3UfyK?0ATwCk#r3Ju!tv+Z>DziaFBuee|=rArFXn?(nbE?24^0z zo2aRb+7AB1|5Z^qF?b!c-~Dd|b2XHk<8S@%&VSWt@AJP!I{({(K7&)_f6?%#2~{VM`k-S)2?)J~R{UV3X{|5krZb+Y|> z;NbP|Vtp1gLeej_E-FW&}Qk{=6k@PQ7kp9(y%fN?bEoouKmRMP3 zn2{zEc#XY+!{o-7$^tx7rflxNGzpY{9Vh=*puk^(jL5&4kbj?M#XkO}zio6F)xVj3 z&BVVJiGN$Z5tMI`XexkxZxDaC&uEI)8?<1_(i?QjK{>NP-&qEt;b!(Emj^U;;x3ZA zS?AC1>B4Mnm`~BBB8!Vbecr6OG5$_eTC$%`@hzBO`Mc}cC-FCU@ztf-L+E&AsxI_2 zs^^$bDsTrC{sMx;OF49x<`m(tCKH4r+;V*EE}cUn)6+R^9FUtwn-B`hHcN`08SNzx(X{$~JdwOBa^u%9lePty>I+ z_p3mJ7|^(5d-wZ-G@ts=*A(7TCW($}*9-BC!?8k({l3|0b9}<9$P*jqo{I4+{|+GU zyt!6DtU%|-4b3mcWDS3jM8p}Wkn0zV`A9s?Xre(Dfd@d!oUsyFIRpm&8uBIx0iXJ7 z7(SpBd_W+6IVAP92UN}EbT$CjeSm)jRA*3ps&bilbnp6@3bmN!ok3uwbs2QpU1L$Y z5tp~(UxdXM4_XCB!yF*3GkSQF|Gc@HdG)C|Y)tGO$Mn-v8lO5Y0SkH%5&*vwFQR=L8gQ4SN?u65+boc!* z6P4~7rh)6Zdps&DtGV(R0>k4;muM$lY3Zb^$w@aXv2syOx}KH?mAwW?itA_K&XNY) z<{sJjU?P9O@72I5kuRwVHsshrXm6$j-5_!!V*t9P{G4Y|Z^nKVV(AbZueq>@<_=80 zN63jGAO@EeLATg4ZW{TXXmMRHR>%B5AvNO(K@i)RZU#e5r#LKlK)|zH`M)%|pD=a( znW#zDE&f>lg|-y8RI;;BnTCq`cc}?Ntudc zK38{sJeheMH26b)h)uqptwBcS9zgT&plaRg>0s9sXs_qFvnq;H<9aq&Jt0{Hy5C+*PF zVWEFJe<$1i$l(?z!yW(qA=R5a?RGEPnQ`V#K2eb9C zW0v@2w2_eF7m+ZS0U;5m>3_*p@&1)DKTdzYq==1Ku;VtAo%@+wOE5nO8!hC(3@v;> zX#z7$@OR1337Ew+-cMly7TuR?ws(kEXnSj`j{6^fbg5pb%~BZ;-k9s^WIV-ZwUY7t zjw>d?qS+V^#ih1Y-}P&Wm8jbX>Mt5X>Mt0WKfqI z?*R~hI;hh>2V)=zBoGuBFiH~1qmd-k;%pxifWUx&z<@v?5C{Sw0tf^GfgnJRKoCG6 z2rvjF7zhLbDguGzKq_2ZJNThh@P_s;U)b##+frQ9a~)8Z8>BeXpN2|n^O$;*Oq0TKU?c;^WC8*+|XXPt@WX>3- z?e^UW85p6lgrvM+nqr)%5q7lN5l}kCP1%aG!e#?;Bt9}%7fuTDKj7BG4coA0p*vf` ztGK%Hdnxs&=jAP!&epR0sAZ4fptE&<`YIe*y?yx1g|np%&elUZuTbprD33qJL33&^ zw5i+h4>k2+c2jvJj^)>#M5w7mc@?FC<-_eAq8t(G)CCBG{>Va)B!>uDSsD_uwgR!h z7A`kTJxKDU=5Q%mkDj-V4+EK%Q_Kx>LJFD0&{i$_o*Q=1OlH)oC4d6{9ypPIBr20g z?V&(7Ol4O?c8rNsWi4bd<`T3s5CeZjs)lMD3LGpIJ|nNBDqbT-s6e_j2wJvOE*Z9r#jz%utatF5XkEAn8=EJ5$0 zz%mOT%~V!pm5eJGFf_#n9+l<(zz`)I#*fW#*CbRHIjAfmEbxkB0hMJcM62OP+VQ01 zwH_UC`_fT{xY1RXNm-~DFSW#?hsRQv?x@I7Gw9dvA=%V_P}T^isRLQ@SsSD{f2=r^ zJ*5FNUa#5TX`UGz~8=)6kXm2K@h!qZerCn`dm=7rOrV=y9UR zmZ!NUNNc!?dDzl#DfF$&Q1~E)-bXyB3?P)~ApqX5dQt-pDEDF;GI}d~zg{f9j0&gM zKSkdeu>pg+jf4j(53{bJiNTz@e~74;hxqxK5{7JIPNbi11+&#|?qq15hm~BXJ;d7U zUJS+|)-DC9`@QYf4#I_$!NN$!@${X{b`)X>e5Ebj5n|cl4y*50o!s2NmH&}5%`J#U zNoG?JYw>}dZ*(Bmp$){Ug!s0(K_Y>ps>;;}jEur%$!#N@jE)DYFOQX zO^?e~0^K?IZ0*LEG^{gEf|#LU@vl%FmR|JAT=uM2njp+U#{m)`{9m#;D$AS?E3E5xpVf~{9n2xyA_S5^JrHuiL;l1Ef3B?=z-!w0ZD*>a zjg#Ld^9;+VZ-yIgP&RD$pBl$u?`&pRZp5GHsV`e{gIn)l0?6p2sEA3aMbzHmAAgW{V{f7c9LteD`$tIU!ZptyeC z^FMKt#w}&9iD&T&h_WEYioj-!``qjTcP9jQ5EVAAx0PTV1(+eZY2OE1KFBzlotkyas;T z^t`zmu@3%+fA_Xb$-Wk<#D4)?>Mw+NNRk!?xPy53|Ic6(Qh$eh__)5?f;a^#1huz3 zt0ALm-NWVf-44XB3IHsK7l>Qu<&e_OsUbO4=yfU(MVj#JQ5MWQYi z!2B|5*v)P$#ARis$PqZ3u7$zu2Q$=xSZi<&m3&d!H*!B58wH+cXpXVCRef#-YFOMK z#rtK3zL&plpkoi__A08M*6x*9Kmjy~=w+)K1$E8kk8svOw~bsBe+o!UK9jhM(> zqrk{Nv;9>iKo+3?x4t#2+_qHlWlI$i1I5bMmNcvIdE=(*cAzk0i@-LO zGxNTA=!w^XZ zuDI_OfQ1A1Exj7QzJ2dw;)BDPepGagN2{;se@{<542C<4JeY$l&7#XEEXl!WXDx!{CUyQtCLY-PRs|N@IN_PW!LN)z8RLUQE{CN%8l%{&J9(bk ze?QWV^m98t_8f3G(bA^0)gE4a1(kq5(F%=;8(`?}%GfA1xo@dTQ9-L&pP$awb>mT4 zjYpRo-bfze;KrWc#ySSGH3qL4s z|B}?J7x`TD_wIy&GwqZWB+PSCCyL#fe_NzmV^e^BKHLU?Z%P3@cgDrHEqCy(&;-oZ zBd(onR8o(CZ#ClI=n&-A0O&JjpAL_}?X9Z)a{EEue=ViZUzEhq3Od-X&Rn9}de+%cvRJ<7^mODPRvXe( zi6l)|D#qWHf>P`i@GIOMUi*{;g~8L)GbP3I137N!;2JQ33KDSVaY;%OParGfB7!Rn~<6C7KrQb*yx^-;`>#j#CCS(=Wj z5o^^Sk&be00<{0xPJpaFe+p`*0{k2$v_3~6nu*1pqxLPy;7x1PCA;#Q4L78}76Lft zs9nragg-y1g&HJ@Sv@f3r(~v8jv}RUR87&v3oYhp65lWqM|B&39vNhLB7s`KeBcaA zuL}90V{Zx=p1rbqL&$%0T?BFV1X4(W9uXdMP^`#;!NSc)T)-;<6KumSPF9;%4l#Rj@neVGdjH z5$g;0eqZ=8zAE%xDy(H7G%$~Kwk1pHhpg$uOaaUL99q*Ae;GmaW#4>KW4I@!BX04? z)`pC$*hdcjXW2L0vtd=0anZsL4R)VX_bC<~EUty^4fy`S!acNB+dKgF(7=kbC`dgt zn5Tz!d}2NOBM-eUomYs5#(elZ2oHUE0xt)?LkAo@#^}-Jua$32t4d^t=JU9U`E{DA z=<@D|G!S0if9(nuuk8GA3T;(}zA@UToi}W~g5rMAE;dPIjSwofpmFGrX_3$c%X4o& zHU>Dft;P5aeVgYSIzsOteciUCHLjHeQvVzCybP@1ua6Jll}!F4+#0$C){ox+u#Z=U z5i0X%siM%{;<&Zf?!2W;3lXO*M-Y_6fFE!Wm3?QG|EQYo*c_p*!FS;P8nh}9NCFcW zjI4d3YwVF&6bC$Wju)C_tY90TMao9R+cB!_Txc0{p@SCLRxbfAw92>8e&iT*3#~PF zKG()mR;|B|UB#B~4u1x~d+Yl-nR|VK$%#RIUK+H<=ViTSLG`K2N3%g^rDKeS09Qk0f_Q zL%=cQ4uvr+tWeT0JVbyLe}P<(N8#mk1;0OHf1K9lJ*fi|TKIp5L7-1LvdW(ldY@)4 zh*BC!P}Nn%Mx-R6-*?eEScoS`Xz*an)O~wOj^I_7kFZvHfiOZtei3?h%EXrDxL{-_`KG9C{c8o>vDGzU6mGNi%N^Z9|AIgq@4WP!Y}p9IkSUq0Dm3y0{(ROl;P#+ zg2Zu26>bJu)RIDAD!-rnZx}Q<=;e;?Z_wHg6u{h|S2{ocQAXXeK?9^JuiAlgojD;0 zo(FSSng&gmb6TM}FQR|Xpi@>83Zaouf2}HSu$ZxtO;!CB;O&T;sMU3C4dv&ILCY=% z9kU~9_@K^xwh;24@?c=l%mN0j8}stzX3-n)>2KaFMI{{0SY?Mh_*2~X?x4M5tNOlU zH}(*iA-6fNHh`_H`$xpIL_v%E-I>pBc#B^t4&vdrge+}O8 z`ua}H>(X||5xqhZL1)?!Adu+RW(o}m@K)Z=FSJkNGS;m^)Y>jI!JQ^c#ECVewHj;rBY&9~#K%X74t#6o{qzOAkq zr#mvzMaY}UCcuR_e4#Fj=LL)BOqa9b`8+MdM2JJo8lH*7(Aq|hDr5u~f4gHlmh(IJ z?=14~?C1dx-M>-T^LG|r*9=+R08j72fm(j?M|bjZ0A6UK(l@V$z_Yy-!j(P+Dj;2r zrL#a?%PG!K^Q7wIYGBr0ifs1~TX9q+$)ieAk9s-RF2;?j!*C2M;R>--FznjukpML> zLIM^3v0HL@09bR#X%kb1f3b|Q9N_}M-H08285#(!_GMr%ibyecFry_RF|S#9BO;AY zeOADXWc`MA1s!js1y%?Vh)d|zm*-+YArT7AM-N#(7eaG$R)=-ad;$a^!Jt`=-lM1l z&Do2r7jOoegZa>EK`w71SlM~_kQ={i?1!-0v=)zbL~rZwDM^obf86i2)BJ{froSP3 zZUj|-po(6%xz3MdqAfRpJyUC7Pp`w1xWyfr9m4zD<_yDB6RCVeVZBTcmr0;$R~1iQ zy)PGH+V|S}O;Yi%8%WQ0DLK!j$WTqrJa7LmWj^)NVg+b0^qY(Xq(BzT0C~!nh~hB@ zH39GMvbel+O@j_LFgy&P5X>E zU4X7y%A}mSYP!ERBAg;mVkfQAg)Gn5+tCzXr=~~ z6|AJ@YVhW2Su#rk{QDu^x2u_tyV^{DR1!du^TqIopE4B|S2Du_<2VLfO^8@ap-egG z5o<~$)_gSTE3wASqzwv(VvT4~m@3v-P)d5BgRA|>(ZSPaER~o5oWQN0@n!7p{I|l4 zu?FgIS?GA?9p!)bI4p|UY0doM#M=rIa-a__YablK{a}|hb`kiaR4z=I>N+?%?W`$Z z4^@Tb+*Vx1<>v~VT7yd6I~qvsm9>VMwZ_qu2d%Xp;e1qY$nf2#FJjmlC~oXN3dLZU zp<$8(XiTfE8Dii=mQ^mdHRgk42i#gb?A}Y{xV5k^z65{Q_C4W*)DZX?wiYKwM!H+x zb!+~K5!tPc43o&|DzW`8D2`nXd!kNL2L5;ugRHe`q_6J}*_XmfR z2S5n(6e*eI2x z$8TJs>w{)U?gi{}Tijj7l3U4G;46~*{wQ2cJ7M28<;s!D}^b=;>ABf%%f?G0%7tOPB zY&d^&lck!OeDv+fR#m`*E==oX0l5S_98JphVHyoXEmjo91{(&Kjv350&rv=*sJsSk z9L7>10GV0%ss<91Tm2d=ygz4?+qUy|sF<^VCgNkF zz@KD=ZOfy9L`oL{Ti9WmWCD%m3w2D`AcJy;_Ae!si_?agE~nfQ(_Cv0Vr>FkfP3G7 zu#Q=n5Q+*G(RQx|@GM z^~QLUV*$ADdSplZoiI1bW$-uU_8qn9_RA@-)Q02zwlT7F=z1_qherA|53f8YkIbw- zv*KgLXHdD{1Y1;FPRv+w$x;PzhR)oqDa1`s5@ji3;zP+psiwJ=^$6iRH7(z9b;`>& za4R(}v28nZB;kM5w3Vw>F`Bz-j#Ymwtx@iFl6iYzA= zuG&RgfA&NHKv^}(IZ}7qQ_QPqT~mC(ifuA)l|-*DP0c;-wzvZoZGYxFkAi=20NX9f zF@(_#`pIT0>4$R&M+{$^z7Xe$q++fXh5e-rW94jvH)biZXcWU)8~)9 z%s=3k>0`$B!Q9)f{9s~5`fwRnSL_`rV+O}3-Ik6VJ+x-scHFJomb>e=u?0~7@*pZ- zk=%Pd`v*P|fH7UwT?ng!ie10Cn2A~05IJB&G$g3sy!*N9`jZm*>FZK379 z?M4OcI#QCqd2~iLme}w6E$SHL%@>qc&+cP7caR*F*XSp>6cbvQMe9f0wF4cDpK$BXCGtoXK^h#j-pB ztd5UoX(`9bn(G?i6LNrMxe9l{1VM%*{bhoB5To~b@ocgkJ!xw347|C#5Q}F#Ot=O8 zBIIr>=AzKYk_`?7CPWubN4r%=1-x?{ugyKM9)lS!ce+d zCh2BHQgn`P=8SHpk^s`yK3Bshwt^5v*z1n5wx5k4H=BQIZIclJlt&7n{*Qy3A?)M( zrbX}Zp5JCvhx3d)KE2bF;|**$nHH-N>^6>hWgw6CahRZa#bnW!lHw7p(I zA{mv)+^ZQ%vU760$^x%MOpdAmbB$I;2R1sm)v`9Z};){}UBne)e>DBKPebd1NHuw7$eQuH=P5}^X85sh_eDPh&KB!Gl1pIap(E_mB=W1ySdyhW2 zP@rgX==^GWhGnt(HHtgg_Z-oKKHLFBNEhTGG?p zjAtZNVJZpT$TuUrAZUmBk>roZi+B?=Y*rSlq((xKHA(12@DC#z>4yjwVKl*ypJr1n zDBE;)Pp?!v%bym`g}(CH$ePwvl^ zS{w5<{HwK}4s9SD0ovdQEO>+jj$rLV)?Q9~vRg4pxQDP2v@p)s&hG|E_lg_5OY+OY zA~KK2PyIg0i-O=xzH=FUBoy-0sL80e9{oNSvn?dNwuJMsZjL@I1ehXLOY2Y&B(*a~X%*XBSp z9KgZJa6pKRO#rxX0BoJ@TH$}d27Nb6=18V_ z7sj@XOMAcp*qF?u&MAKrN8Lfd10*_YTYfvYXZQTy{0V4WR-lRGea&*ZTbvzWEduwS@i&E(@y9eTMZg6aF5O( z{GX4nZWbk1`~*!8ehFCqYin0;*t|;oC=Zlb-dqfT&hjBNRIY+BIK=Ijp06(5O*S4vc)*GEj2WH_k5L9j z-3!t)0kkm@0(Gx!C}fLWjd2$vx)&j!j0Sgvm1sz&Wav}dtwzPY&?xTZHOFvHIyZpW zh}Z2^t8On_r`sz^bbGny_A2!>CVm>Df*LhLjRRRLCk%}WvKRT=3$1Oh+;KLiyF7pC zfqzlnMI?WUU4;!|F~Q;BNW8=fppw192jsvTgJd@4kiER$rZ70mbiA+8c>C4 zYnUBp36OqplhUbz=PF;SfE{So#O9C@oGRmnjr2luu1*mNAJIoIh^xUgJ$iWr7P4Ur z4Z@4QbZ0D<%gV14oZmqqy>|28#gphY!f=0|s7CYx7~}GDP8qG0Xog<#+q#i3 znPDvSGWug_DTQ7Zq;%(NakHz!HqsLadhwMBZ4fkq>|q2{lT)h_!dB~-l(e9J?DDB9 zI*2%tw7EL~;_Nsf!}rzW?_1KcQ<3EHT?{WpQJ1?Aa;YaQa_X~)PHB--wMF-=h9Kq` z648G>9Dn&j-Cw4*-n_@_??rAH-6&8}w|^r{j7f&BUF+Z8KCO-uQV8UV2qyK2aiPF} zUidy5+pS4G%Tp+wObxeUmzM)6aEe{HRq;;lh+Qa+r&Q~-an|_uP^gc3(p%Vl-!s%dH zrj<2bw*mp01ReBf7mZWD>B?4sjs^-ziAArPL0gz1$(mF;+3Vh09n6Gm_*mfUfDElU z4&esf=;8$X(Eg;t2W%qWqASvje=WV7SjQaoAhd6Ig5(6u+^u}d0#DA*B%b-R+sc2S zsf5sTaSqR^5PcFyo{NXV_JHlvdfP)O)gRftrh1FrabD-rtIKB%wmYz3qg03mvn+C>a_;k297Pez>Pi0{Ga*`TYN&z95pUQT~54vO+2 z5j~-8>&Ip7u#s$?c4p(e*=a{;o3fMEjK=H1nhwgK{iOMj&?TNWt;pA;SLbK`ip^?q zf1q{|tqFSCP$cP)o;KCZnVxoeDl9p~P=Fznysml4Zs`YlR2~s;_s?TqOd+niSGI}n~R5Y{Thc& zv*~*4SK=hlcuK<%5OhM&fSSEv2x?%89OpoO7X!7|RKu3`wHxBX^8Wz6b$pz~u%|}l zv4WahSpRUHwp}ho$?qn7UVkvS{HHl1$*O770OTIWy6ayzehoqm6heP3_}HgggxYU} z8Vz%wL_+P*&P&v+Nx~~MVrhLsZR0aE>?154Jxvy({*Hp7rWYc8OKKTv=S+Eq+ID@M zPIV5oWvX!!G9PM!7@zZ)H;t?z%C|`uQA51IHlo&nVve;qdjP7YL~SgD5=CF4h9o9x zWxWLGCTgj&?=n>EuK0gWyIp1IJE?xBM&)?63O}*;zffwYTDTjfHga=yOsNfC!0vPa zrl{0rrT5}n`44PX9D2v)sI?jU6ia6!-vl1}vT*biaYT*&%CXA zq2Gd%S4Dud7gIrhmw#VZHnlyoZiS%rHo#47fO6@9_|1#n)HZ*VA(pLvdPo2cHOd{o zfOw;G83J?TsNHv*$6097Mw2=puKvR7+W=Hs>S;=#nhObX15&iCL5eldaPN7jCNebj zpP#`wMYWwXe6Oe`WKj)9bN9D7wZX5`j%uB!_XT3r2s73BLxd*AxQmxJiEfy+x}cP5 znMofIK!kK4BIAE82cGVU$S6{pF4Y_(3^3J9B|HDd$=hLDi-#NU{6ME#SZ@pss;+^ zw$826Qk*Bkb~lX9MWVH;&A}*9@~W2pQS{Yo0gNC32tt2>Cg>6T2!TQ{{u1~V1fD@) z1;LLXKnN1_2LUkma~pHNnwk5J(%cWn=6=dARFLj}n{>aDrTfEWx}S*C{X*!|{o_R4V>Y^d%BT6Mo`}Z{757qI0Zy@haH1dD`HYUllNKJPaQdOsP_`*Dume=eo>!!*7B4eI?wQ}5@pdOu9o`_Z`GpXclSWMc2f zDSLk#+WUpp-p}Rs{xEUxk45)>mb>>`>Ahd2@BLbU?{^t|KbqnD^CZ5XjPdibz*-=78h{w%ZaZ?k>BQ11KVc;DaX z_x)+Y@AoNw|2^dQLoL5Q&H4R4(eF>Ae!tK4`@OW^U#9#0XyEVX8GnB?^Y`|)3jBRm;GgCK|2{GBUq=Ifr5pHr>A+v62mWh7@CO=#e>@}j zXG(&<7!&+$p5PBB1%IL{_`_krf1)k;!+F7 zV$krnnTG$GHT(l*!#|E2{zl*MpA&zFKTjH~?RXViwWLppzNDs^w8U&%03bK6Z#2_HT`vDR_AP@u)2m~QO2p9qcAxH?o1PVbzpb#JkjUX%nC1hIa zNCcg?vSURh8YpvKatl^SH zLK=;$hVLii?-$W874JLn%|%HCi_)=X0l0~wEEG+fT7A=O;9a!oUDRK>Qd0(RGH~GW z1ulHLqqQi0yGy^dW(j|}iIbwqfL`=82Tch+E-3s5V$|Rrtz-P6(qIY`$^wn9GVJ7%i9pO-g`kPEOlaDOIm`}-7MWa5HKp%KnT*lW$ z9}(%_50Y-);mBfYpt2KxF|mY>-185blksok$=%_#s=DDy8cu&w>KRHOMy(n}jb;=V z`=dkr88$%JDSMxxq&fN_j1n*=z_vz9fRSp)o|kE$)DFvEwzS{W`H1j?@q=$nZ!kqF z*{J{RuFkd<4T$+saAcq$4&=hOo)we`9KDVzPyyv#Sn0-u{I=V^ zk}no=|BHWiw5F+N?dXJb%fNOtAz0MyXw=I5`gRoPoPd2?{LK5o!rakjR0GB!-OCZ}7IpQiAMvOk^HqHjRIw=25Ofh|N7kY;}(6nj?cYf!#~c zYbv;!v-Bu0>+M?ixM|7G9qCy&wMxJvITRR3i{K%WyDMte;!!zgd$|L-$0C;vi70mp zqEpr&<)Pj~4*l#YtN7>*IJH3Tp#gmZu)+fq=~+FKTY?C+uJmJV!$D$ZVLU`C^kc=i zQ&xY^oO7Q^IH7V9Y1}R%B~lxk`5>b?l?D^3JgucBQnXp`CQ`PC2{b&B=3t46Po$Ea zmoF&NDt2e84eVEVigfy?NvTNt+S-zF*O`{s($H&>qOsYhT~HOf+tB#QfRPrL8Vmr` zJNOOQ-tT$bG#+i7DTCr=LCdj>RGlpTV=sSU)XIeuEQJeQvFa7-i}-=viEN}B^1~AP zCSY~t~%vrjRvA@8Hybk~zNZRZ00;kM^<;6Lt|HNB#fTy!VQ6Ycn zuxD~a{0HMSBzG4pNFaw`VLiJ-m&`EgphYZ|!kU(pg*>q}@>FlLcho(0@85WsE2O5P(!t z5YGMFyMK%@m9$-i4^`4R8#IH_o&A3XFDvOx1MGogsHU4U$a=D)YV+@!}RkV2H^&)t2KN?B_UM@Qqz%Bu1o)&Jpcj@WCRAsB4p)~mMTAhCr2_Za`atmPw zF8u1HKaMC}PKYE*-OpXxUlXOtKl8ZHGjrJbSQv%uhEJ%q{k)3y$88v;JSzVmqtt%1 zsZZ&$WspmPj-lEI-fZu}pGRrw6;+yT`6zuLV152Eq|~SUNO25E1xyb}rzR*h9#1MM zeNm8Qi6*7`e_^U1PfCBSxfy3(D5bz-6{3>E5UmqpMucF#ec zD1D$u+kzwlw;OF#S|@CqR?lI`B-UdC!BeKv6wW=w`h>EaK;26iN|6hHJbMinHDQ1c zu>M)4GnK`C##~OW(lX>fd6gEG=k`@9Pfm~NC06PJO6jpuH$WfDN*R3bnw1v57pZP< zBwL3)(cy}j>GgDv?~WnUxKwZ(bDDHe>n3t3LerUR zQXH(8l^}Cz+}lEPDf(HL(_%23o+(H{umR1u5M(KGTd^bIxm`Mts6`^BiMdNn$aA4- zl_D$vFFg62Y=z}`DHuaoP7sNV$xHvVQ|_>mzI3 z_)F9E5tWucUA)9YLQQ39x5Lx|M>MHS-w{!#(Xyy)r6o2louutm-Xo8~nye?+^k`UA zg1CSF)Nlt36!rf#nZ8Y?5|yx_vC1KgkPphV^y){MK!XyaM^VrqSWKDrnlkM|w<=j3 zQ@9;GX)@mk@}W)O8$Kq}QW3ZRaNBYFxogVphmvY;zwrg#enK7N8kP*>c0Jnmwx+?On>{W8i4z~ z&w%@vYy=jaz7!>mHYRbmir-5m;09nF!zsYnXF*|C|7iEa8iT#sPbnFG7tn31Pl_% zvlvD}6viO!2NFOKNFWI8AV3fVK@c#4AP9m`1PTFy&Q5_l7DJ|BQsC=eUE6AJTq&5ebASooH(nX*Y20D2ZWe-?^4%;+yIRBc-5>9>m@ zpUw2U77DUPDD+PD{rc+`>N3HVp4$i=2`LwfR+JEQq1&OFur5?{@oz_MhA#Ms3@=1y zC{<@@*7(?-q0&$8Gaf=iqcDFC3el?nY$eR9N<)z7iGpz|dSMrt+Zm!rz-edaDP)oUJ$1FixuR4UHRPWKQcID11R^ z7T{0=r~rt@JZ7P9E(|57M_&t={WuOS4#19k6#bB;K#_^QJnDgQxP^aQR|TVQ5I;Zo z(mgn1J{>wj9g0Q!k5Y$TP8~`yToMi8dnWAsWutNb`k26u3J~c}dV|hrhrN z|2do|58eJsrC7~FxyOG1yN!nw^ibE@yGajy{T3?rs+tXtL7m4~4?UY|Q9$-kewV*g ze2-nyfA)M2J=dsw?*wnZ1t_BfPf#c?L>nIp&^LIBg?;`WNcvC-1T|asp_x8u_U=P- z=DxV$_@O^#3YH%_G)*Kx?-ezltY6c8{-LG*p=POnGGMs_AS!>awt9U4-qFu>=u>ey zv0`cE%E$VD0@_#|z6Q@&@7KOw&%Pq+dcl2j7es^3Bq~D?)J3$hEZORl5CazjW@}aiL38 z57Fo7uTyL=&o_U(zr{qK);r$Ba@ZmHUg$WWq0fg?I=+c=_M0f?!HWzT`o-%Zk`pD$ ziPnNg!EB9iD8knMrxTT_6P+%Yz6t064DQo{mLs01-*}=sI$M<|+9otO*byMe;V1f@ zC=5WyMY&*pR1@ft{iHOJlnl+IHK_ek^a02=D9Sr1+WCJ`kS&AqdJRGUavmE9A0m-< zqNw{7Bh+oT{Ik@u+m4@XrNu^3HC5lBKkeK0REf4$>hdC)FoG042R}#i6$>r>AdE;J zPEj=o%ZNx316FYsznm~1@?Rcd1kPTtM{!2R9Y2sKsF1gbqDm%3)h`T%3;HuFN-Z!> zZZpdNlaGG^&4El{U&x0heVdpFF+BbB{|QiIOwCNs6)i+St@3jqnJeY@`#7*DYy;Sb zpKU8^>ZcEzR8wnnWb9DxWKl$zOg=l6MLjre;K#kqXZobkB zl~pMUFiWOFc;DN2z9+!stMXJ5!6SGhuv<(lCsc;%$|kYf%!F9J?DljQ&5b zjWVJpgcv2s?wokVf-5X!(Y5emm%m_X)&KUC5`x8z_U~t9&A)6P6z0j$7{%gO6#&O5 zDB!-39itGN+zVJV0E7m%vRQ13BA_xY4^Ds0ZZe9M`=dx1HGpVD)Yh#A^k{bPGAaO} zonc0$a6Rm0Mgu&wT6gHyqzk5UkSAF=G`!^Ir}Cj+Apc<+XEdgYh;l~X4@+U^KT@Q+ zU>V&5?gejqE~rixPTNRNm@}laQe)0c`LtGa#n_3&=F^*Ej2$wfv_EUd8&_9IqZ@yb zMo(`kSJG%<(x~L|1r*cQqOS7rv3j=hpCMuVhAZ2%)U zgXCZ8{ozA#`A!b=-vWQnC=pnrI@7KdHQauH<9k#d3JP-L`-@*7vGE-k=!mDrcX%%t z0-*7IlfW2Y3p2hu%AiA*!ire{F}{EMdt}G}#&=8#65!#*_ZPcB;o>{kMyO(e7T@W+ z9hgKphwo=&9P<$cb4RVgB0r{w9m=sZb z=mT(>3JzLF#SN251QRtYjvE$4@Pcti6BP$>2>K6l-N6u)c09_CA{Bx*p7ejA$E`ep zL`kvH zWiF~+_2FhLvN+(=#JX)zH-mpRn=Et4F>NqIQ(AO?BDmRm2l2WPe{$sNpv)be@ z>6&A?mCQ?0Nw07L_OS4|emI!%Xr=YyWe+iXOl$HgpTJu2M;Q_RZ+?G!AL`>7WArv> z@iIn1b-CLME6-)M^G7#AWm|!K3_H&z|D53X`7pAu=9)}=z4#|1W}4fv7a0}ZPyPjc zw&%l=QS8M^;PkXUDpdYqk`Y1~EtvXCyj-b_4$v@U|BV#VE2HxyItHK{m%5iPqb^t> zQm&Vg#3u?4${?Pw3?Y9AqO?DC1gubCC@C+d9ycmkW7!uz2Ig+riZL6DX&!74mIa+BCy79u-6ZFtj8ZCCmX=&6~5Pbevm2zqrH9JsO(JtBM&%N_Y zCOTtf{;CH5M2b2QP9~j@i+ym{QP9J=->;(&*ipp)Q#9;oA$EUM+#AUn)qcN6*_KD< z2ne*J-MosgckO62-12TaI!0_yKTA@-4f*lt7C3h_7FV8DceITzsU}%4?Dfw1VY#)5 z1$#yzVgMdIO8*;!;n9|*vOPQ+V^z6ci)qw)T-kTzy>i@lV0)O@+$;!Cr+@UHeXb?oi05{mSbIV<_Y>u1jn}4GxAI-IhXWudXtZ;38Z!SPA`sjZzL%{|3ugJ{-__ys*Z?9r8gUK2-#=vek{H244U3lY5K5S19? zLggRn#ZiAuPKFPSh^Af~4)te}hv2NqKHcN;_=xY)+wU=I0>U1 z&^PzSi1uZGTJ$XIRve$%K=Q?>5smtd2XRQpMij53d2;)p($o>fc&(?ic!d^oe{z&; zbFh7UL@7mMZu@;i%a&NHGwJ{UjX-k0iE<0NG5HpR$$)o%lB4)MO?uv0s^h-UDaFrA zrbe_dT|VUF3?+#^CW)5kyoZwLTuP$33YrRWR3WbXW5-Y>>t_IBW7q9QG;k7?auOZ= z5(ZDATTh~h73TpZ%7YE95nHGcC3=kwCH>cP4@rqGN{IqcG%+Pggp@9t5GX3qNZ$UC zBZ(jeL-Zzp5?CU%n>52q+K?4vI%Pj=~^} zG2T5Q5C{SZ0tA8}2!$X72!bGV0)zlT2p9qcVGsmi5rPqJxU6Xn(RF0wRQlxP{~#Kt zY^m1Ibd=^ZZqFH%tGLYq2aA(U_^IQB0e~PKi|DdzK&DV2y0d9)(<1ry2XUprt8p#C+ z4mmTui-5>kJ8cdUIco-4`#DhLL`d_Jikv0^IAm3D3*&E7&Si#EPE8$DIgqnmPNkI! zYW#9SApXf@ff*X#pKO2;=H!Gq*`vRPO@91;qu|4wQtga21vI>t>uO+drhqE5)9kx} z!TM{h=$vGOSV%hOES>X%Nm)rL2wesjJ}X%T09?jj1ku&GxHn1R6BmcdonqzA$fC}* z+$jib405>1dhrKmGa|<83Ei2{NmwV2Ihzp}P^4j}ID`HRt|l5MaV~meZ?HNhI^cDG zC-hrUg2ie2KXX(4H$5Ih2xHi@3$Bdi>_c%SKMXc8`s-)W9`7`eclxO)1>E)+Sl+3~ z+!F|`1b8kC_)uu>qlm;y^PMp+L=}k0IOt$R+6iCUDf^w;;hA61V#!_R>`b|AtWH&* zf#%dvg%N>gWQf7ry3z%noJrOhLFfa2%Br&MFLHi>1o~ic1H8}lMcA<-(0ub1!kdQ9 z+U4$N)yNs)$$OiLAW}ohO|3-mK%d=p_9`|hPD?}JO*%YVeh}WDVIFbcoMXOHnyt9IUAh7F)Sc3zBQt+@6$VA$JPMb-j4;#SGctSJT(;skuy!*d9 zhd)6Qx}cEKJwHF!>c6EH3GZ?eYBn%^gjkMm^?K= zk3vNkgZw=a>c09IyV?b+Ni=&;&i@&Q1Yh!=1HI)*V;kK)Ymds3M0i3lG#he2+93Eq z|Lk1)71xy-Z_{1M0E^XqP38%IKMw>7eBL0|7-jG+Q|Euh1YoA(`H^16^A*t^GdS^et_Gv~wq zJ(DeYOaksN2&PjK(7-i>1%D1}K{M+cZ~1*jg-_H$Jsg3cQAl)uE~@@?sD$FtQ6lP@ z?v0x9Nox#I5jgCe0azxLs?KxH15#~0lU4K-J>uBBYWANGRmoJf44y@>;oD4b%=N2G zXFU;YVnLXTvV3Y`P?iudeVP<8gn*-(&t@Mju9<$Y98>nz>_eVVK0@-+yGW#Wy1%;Y z6!uJ(Z2)lq^-E@dnil#zU#0Q?Fxe_0{EsLGrvf^C>wx|T!Aw@{JigC+FXU&^r|rk= zDCm`yK2cj&l>bxiz!4NgAKG0`6U399-xZM-lZPoz$xkA{Dks2&FF(J{?rk!jQ4Pu z^pg{ZtIPC%Ggeg*=)+eaK9Txay9%j6)z3Y(GmjP045Fy86uK)k^qi1JnJa&&JpNM0g-< z_fs){{X4y%PycbyZl-Bs`+j1oXaxZI?BCBtEGN}}_twLPN{aZvEee0ylU5p_2Khso zkrS&F(Rly!EvmzQ5RN~WrTuhzUw7LvS|k2_Q>^^?n3&|C!O73E67y$W>u5W2{!}`D zdJc&7{MiG}8Y$v`7vsLUe$rLT)1SL`w`eRhPv@4Xu0OAkfX3Wg3is>s#_Dii3EZEf zz!@%h1fVNC^cet6f643DF93-6I3NO4Lcz&@VRwB@~T#0*AdXn%vM1CJmG(K{suQg07@O> z%4YfK_yp15ihccmoSl-6QJ0$@K7s*d0gu&l3@G>}A-=f&KJ;(Rgx;-(cNUW4fD)a5 zwOyxqfV1n|rtE+gKCW;c1B}WreLRoj2;YkTZi#mAbIs4lEXgl<|MbsVNeUYU&c=a+ zoa6zYYg*m{;EX^+SNreJJ4L=bEBwPgl{{M}vVi3nrZNF>C0#wRr35l@j$c->U7Eh4 z$TC)-xN!%V(lmgH1qyaz4E9et%y4Ia5HkH98db;^-Xa(pYbXwxAZWm6pbF4HqwTK0 z6{dkoI1SVRVufE1sHe;2=o+Z#*Fa+@MLaf8`c~W#-BH^>GbALHzvw;!lEb70GFs(h zy^@I?y;H&^pk%O!(|TE2|YYcc2K3WvF+ctKWftb~y&D z?lLV5L=UucNrO$j_6+FLuOsn+*2Ylr{g!{(pY6s8)>;JaeY3VMu-Srt#XukdDrhIJ*4eFsx~rg!*6eh1Nqm* z)i$Zz?FAJfemM<^+4}|U{(=rE^aBj4XoVa1u`uYP&x~VqR#|YkXEL~dk(A_XPA26= zoAxS?O7(=^j_Ma2J85%sziv%jsp46o*HcF>ZzgDQu| zbq9?}1Nnl229q?fM*8uHy+Q&kpSp!zgAzMP(WIL0-=_lNcu?stA@}i zw-UBiA#PaBgU|={t^m3WeRhOUfqvoQQwZ(YQu+#^352f2!Vua)W-Ce>LN~<0ibE*p z5IW%a;vGUil@l$G&|VR`R|}~;B3Vx%%zBAmC#Ia0Mv&Z5TY4$e^38dGNDA4 z6C)k#!9c+E)+KVD;wW3(X6Rwx#wgTSJ0~PZp*t)B^(ZuU^Niq=t2+#GcYV1r4R^W7>vTY9}+-+AP6J~AP@uyf+0W%5CkCr zA_NIRM1T+=1PB3&ASi@07?iejBuyuT)8L7@zlY}JRT@Vlpl$yP;1N!dTfjuYVD@&% z{y5TvEYL=NMIhOfsT;sS%tUhU|>thgQPKdW}{J8 z*8~eb!C}vT-Rg+voVDaMjSLE3I{U`0Eo()enppMIRgqFwfbP8eSoDS zT}H})q);m-4+}1#p5}*spG?yJh}~jn2&RQfz}V$H-*^#ic1t~xL{fo0J*kSAH4hw? z_IFB){!(d?q&iyWt1K-n^(xx`?SKX;hTzWDmT-%ItlLtWMSFvwzx)CJ%4Zx*$1Ykt zRxWAJ&m&+GF|qH8$P2|jK=C1IKcBrXjA?2a*bP)mDg%wQ1F9PBQqczO0Uo5yymz~2 z*2JFM!X8Fl~>9D+2gQHn_`j#`p$FDh;;=!x`e{>!g-#HK%4!Xdq4Kv+*^Lvoha zFY@+(NC=+;b5xbq+g(*$1yd4ymG%d`>S~fKD=o3bU5x`Z%>-H?ukxP|{yMH{?}P}6 zvlVS>+AUZ}J^^{gr=x^GIt5gPBe<|qdtEC@5*)OCG8%qmm$qSiL3{Se1O@9Zfw~8w zXJVb!f)kYTs7Iy|YIm;3-_6P^Dcfr!OOP0U1=SEhZ$P?+$reEH4dT)K=3oV)SRmIJ zNQDZ55=e=As!!qJq{(nu(}ox1U-FQNuE?epd&wGW8<5h>;v4Rlo7PK*e!!1eaxEbA z8?*KMwoH}RsG_%~A-JVU4zoG!4E#iqN~e-qO}o~e0m8AtVZkE8QV7hUi3J5Kx?T-` z#FNR>S~Y#nAq&@eS{2v~%6R^vcg}p%8~-5RPm5TQS`6Ctf7)#{zk~`Dptf;teRH-{ndacCey?FyjC&)o7!{<;w%-tdFNlJt@Ad-sHZW4R*P(Z#iKfk6@! zYoIwAEZFsF#YJ_PqE@Bjpk`e&b`-{cWvPI1edsO0?zybyiNsNxIdB}+Mn`QTb<|d$ zGQ`H@!zP$LDn{6fPn@RS{}Xmnop1+dCmLJ?mhARCjBt06B<%cv!o5 z?1vyhb=z8wDym)e)t%ztHYD(<`@?uz3v}b6?xgKdL3gOjN%^`BPPMhzO$KPN1RxR1 z26>NPCC#nf-L0$*9iGJ*NE*|@bcqe5!5VO_EMRO->`f}+S_?ac8b;-h4nbI1M$T_! z#_wzH8U+s*iY^Y>{5*;`vIdE1r6sDpFub<7fMK>rk$6<`BrtZTV* zqvkFL3#Wibe2SA{&f`!X5?uQAE4^sJF#G2B`T&Ht%P1fVUd#VL{(F6;>zg7=+tDOl zsnK+a7p2xSxn8%umar@Zh~i#rHf%hUIJJ|2Z5rH!>^W?K*^aClJ%jImXkP1UIR<)X zUkmyXhX3M~?ulRPmuckaZ3i+y^fPsP@1hX4_3J@#T8qi6k?9z~Hu*2H2iOWZ0q;C+ z`~Y3w>lFqWF>kO<$?NI5k3{PLDDGBJWyU;t zGa(V!S zKqZrKGnGsNk5w`$ysTu>Tey-*RryLLVI!7If>2p9xgfM;(ty^I$t7}2CN&h7O!^zT zWKw1Bl1W0=ddVb*^d*xY0Zb-8V=$S#iD5EnH;KvQ$S@|8>Um6mCe21NnUs;rWRfA6 z$s|&3CX>JMOeWnYG?^S0(qz&wr^%!zsV0*e)S67v1vZ&Pmf2)-HMYs5x#T93b_gCzF!7P9_aXJDHTT?qrf+;K?Ld zjVF^sF;6D#B|VvcTqgEp(nRmcq`Al^lloDgOp*jYnFNvjWO5(=$)t4zD3h~6piKJY zK$&zI1!Yoc8k9-wKq!+)Goef#iiI-iB^k=(rf?{ecKJ{y-9<#1RF@KEk}fF9B#^8q zlLK*4Cha3enVb+BWl|zH%A|qlD3hAfqfGJzNSOqaA!YJ^7DmdXYa}U?hQnAwoK=Yn zl7s%j7=BX-EDE|ynK3dZnoE=R;mC?coJ3Wn#l!~BKiiulu@Yxwf+J<#Oq_u>VA%L+ zeSsU^>{44~{kb~>iMbo@Z>gllWm4xzLsbP-oaBj%>)0p!o*Uk-Xh`34gD_$r7AJdh zUKbqBe&3O0lhp0sNz2&5Cjwq z2#QC79GAUC0vCS-AwUQk0)+q}z!3y!s7Oo8r~`D4>)MiDf0JH|kd|ReuO|p>#lATp z_|m^rY#dh69y73Jv-NouvwHJJW3KpEcT^LI9ad~(LWZxdOVAd!VcDTZC-$|mpyXG- zU@_xva8!O}zU{9zb{EIBS8Y%r`gKu+L8}QOSScbDW$S-AhLpijPt$P@%CK6{=T2b34ECxh)Ag>rY~^EfUF)KFGi zVfegik6_{co#-Qk9tjr86UwOJ)u&D233i2B{wytis>gJ^^3Ds^sWFeeA1d@;u--KU zmsc>@dN5c^SZQg5R7OsO``>5JDAsRWV3;Rm-DiKq+EXq8h#g?WG9Fb35c;{=H`rP) zK&@!p8@O?H8$Icp6+rJv2+L_YK<~1Nx%R?cL)bXJ`*g=B)(~O6#4iYquqFTfIHhxdHkGh81QcHhEBN}6xP;yEC`@Z*8?`GD1(~o^nXr9J zeW8B=lGk1@^|qHXxH(}@E)vLpLp^reC78dc`F)aCCL}W>uEe5zQs_-U!(0EV6!sKW zk(@EE6Kfv)g0qnCn(vlO+``_y^EYGlE(B$72E}z@7Z-e>+h))*HbB2ME)jm5BK`}8 zZ4`_i3ikF6dP~$~-HvVDaB$2_S#W zj0-m6TT)6L3G_3u7g?qBck-!;eU=|9#`aUO_N8>LCV0XCU>}+Bw)u9EeME_&VW-%5 zJjJdlg`N1(;~=P55>t;TV!SqfkmTekhr@rdr(IsB%%qVG9j9Dagt4%A+op`EbC5Mn zxgv?N5Q9_!XvZ8?j76Sawj>RnV=sTk*31Ivp^tcJ)rGzqV=pe0t2SYjhK{k+vtOgE zJQZ9$M)?GkvDv+FQSLkwQb0x7?wGNWyf9JL9|3MaM0vuUvB6DwD0g%5FAyMUDEsuT z^g`$kj?HtqescUfesBBa9T~@_>S{i@%gMX-nBJ2evR|bORgTBz9*^Dc`TT#GMxi|R z48s4pU|Unn#l+Q{s3UOLW1o6wunPaR$HGoz=EAU8nc7N@UzIi~#>mIwjxCprV)L<$ zwqtUUAwcNck_-IB5PXgcV1spp7{vg2&F=fL%?Gf>Ma_iu;J*|X?6iWcHKU7>JvJ&k zISxj)szMgfUs8`J{$I%c!Q6il6bb?IzvQ1uu-t5bL5POHAv=a|ZHwR!nS4-DYGZ~d z-8;%hWW&N~*@BXgKj&W)r3+pL>{{W#LkDMuvV*tCf=oQG1s|Yt0_eeyIIDq}`6+mx zkxh2JSqrKr9dWT1XeNVbK}^3L*-PAw@t1<<>?uV-PXRx&&MCQRA!vW%&c0085DQu) z3e7XD69Y*$*{}j{vd1J@B}HnqNR-r;!1`3C1}Oz2FD25PW2_FY`=oGD7TB4uma)bl zz7{Isbvoxnsbr_bT+SjWc>6b;g#xx@J2iH1yg!oIYut)@U}9atzb2-Ip3>D) zwh58cE21ZM!y@#GK&PyC&&?1{QCSJvcdmapjCk^FV$WQmWiGot zr}#DZbvCWa8cRwg8-+Y(S!E+7aoMN}yxDny^MWi#5()AT>pUXP%3^R-Y_o!LK#kDK ztk5Li<%LER8m|1Nad6o|cdO=K5pmg^PUK<5(GOr9JbbhnIb1DRol!Lw)hE77NlJbV z?PGV@QEyiZtF zJX}n+bW4z)@9Ma7uS^xf&41Fmp?Q1k$r=0qutexDLPz@%0Qj%~89X50W5&AQJRm(3 z4ave4X+^`5}Gut{?L(DVFS|LqLUerqVt~NH$GB?j;>_EtQ27l0XNfdLO%^$tz znQgCA8Rcd8WXxqMzK{~USo6oj_zS=v=xHQT@z()0Ld}>_+OSW-#7v_39@cztf3hQz z_g*_e_>FUdFa0WzBEf7O3C=ev)?!C~m~=Iq#>OX)kO~9V@L=KpsJjIdSlAg4_dg>o z8^iZM@zq6%XYXLqjDMt%i<85xVQ*OoJ)fyOo$Vu_2~p7%LR0e^ZlP-H%Vys0*KnhM z4X+=Ea<^H~t`;`*^7uzpL6gpe96nyLVFL#lFu)MV2|y#kVb;UvM0Ao3@k}<{&t#^* z%7(kKY&d;2KG)A9{(xVYl z=R_z}tHakc36^YKX8kKbCR zWZ3+G^wp#L&}07x3`27Bvs~imi~>WzfCLHx`hp!QbKQkmib)XuqZ=P32`iEbdSvf4 zJfr<*&eK`wH-F41`FUGlPqXse+uVNrXfw_xZKg2E*V1_RLPq1mBJ;|Jm$IivXw}WB z^a=c(mNyeTO+_i)C0CdxWA>g%aYWaYN4n~4K=-w)Z$Yg z@h;xHY6ceXBT}nY_>_EzN0bArPqiR7J-7c<5oyQ}V}G6rDQ%}23?GmL&d(kQ4ngn~ z-Im!nyx24uF)7`tT`$CmoC2Hrt|~J&xI`R&2oCS7`t!9M_KQlYZ1nG}<_u)?21=$k z30Ox8KKJgP+tjw}x(UH?RsQ3fTU(YYKT9=9Aylv}Rr{9eB^rZssh0Y?%Z75uUaB6# zkN{bGl7A_;ude83-UDY3HIwOqUOWr}dlq%?S!97A zny9A;(4vDti)s}5Q_$k@!>eLxg$q?!caEC+4}YEsA5#hBa2Nr zg@17e0}6B6!hMq=xtb8DiO``YYJ2ma)I@Iulm!&_7jl$VLMkXFY&7M<5)#GuPRWR2 zNh_chKxA_38x)d(GqY!*2i+Q_g}$&d(SqGe}I+BDI>Vx@->b_8oT1Fi(ErGGz5FipYU|0l5D-Kgq;cy7nXPPleQoN$aI zW!#ohl@nB@diTrJNtNL4;{LoCFC|5R)ka@IP8b~2%75V?e!;&UL{k2r%OwDg*1Tos zwEnPlf!G?DbOhZ#rXw=gk!bEV3L{9!RyI`3hx(j2QhI?06vX-PWt!Wd2zqO*SARmD z%eiGOu*UWm=}Pq=|NZOt>bgAnMJb6->s>KQ2gfKSFcLPc8Kv7ZN`KrJ*fdJ<5^4;> z&Wc`D(1W8?aXWGy1Y>zZNg8QS8=nPNGnOwfn(8u^Nr)C0MGFY5&T!Qge>!Iv*cG#3 z!VS`o3bO5*H*k!C#!4~{Jkj!c&VPQ9$7rL?D)MaB!{s<`v%219y^g%NSyj5^j@Y+- zYpj3|dcOZi9)Gh2P%L(;9_k%#s%Tws;jF&EtdCTj)h^D8C|TZSEOS<@$o=!VQ~{Yl zf!4Kys6ca8vmT5U{laYmbk;(z@4)G-eDiJ3ZF^@2yw2KSXH{|2WM{=UR)42<*5dKv zL%vnqx}U78+?rvu+fa7Tp;*sqY=x?OjE8}H)+qiO^C_nkTI(-XANa5FR2?UZq1EsPiRQ`1 zRAh~LjGDF`zWy}(+*C`n{(oHExC~UZE-d4@q7{CTZP7|_(c0;v6*G!h!@y`QyLeaw zX|%>lX_c#hPSw#m3al*b(Ha1@F@YbgMR=YQhO~adL98|Y0e4y>kOOO+3ZnGDAebKT ziT)`NYJCZ{Hle_teCr_il&cbGM>=MTT1zgzFYItDBCb)ZnS1F3B!9dQ(M?$?Ss`3g zE8C`4{iar$7;mZ}xJn6%_JbeiUKJX^SN}D)5T0sfA*E8kAY86$rJ_wL?Z2qtZ?BLe z00>=t6dPL5rM?vfv!LTBAJQam%CLi6LC>$NRsDlyETJ{PNt~!$5iA|2+NWTxez4Ym zHn#sp_#)O?1OO}6>VHch+p$(V-)uVLP+7)ogvStRue?zx7O> zJ#dTLs*;3-Z%y5oWPFd;0v{92#|*{B72#?6+PLzjv26Rq zk?Lm{JBJb$ss6%{%EO0$Avk1OZuH-Fb2hn6ap*iOONePvwcIWJ|K&y}KV}fUM<@)>UaWBLc z?mP(FpQ~xSG;urr@8OZeeLA6QCczYK!RQT(v^8~IBSb*EQ&(nBT{9+7qJxG~lPs@4 ztFDL?!^yC^=D4P(O=u0))d{LqAN%gi+v*4$)3e9_mC*r0FymNX*LhG3Bd2JJo%CqC z!>%n3?0>1J#~ZQ^U{g+{1VMaY^lTZ@J-*zAN_#@pX=8&DeaWb42#w^@%P=v=9ALc46>C zTL&jz!Jd%nYF47QVrP|R8LvU~EAM!$nyW<3?i2vsBM zVbkL`Ia2aUIWwB3sWkz<4t5auO<)Q-34cx*%Yswh4rDHVeR<84^zFsG>P&LlF2@7N z4OW>P;}FGbO5#|}>ur5#qEIR0d_I~h9H$TAkjtGzxH-b|jfN2Jj_(j|;0maflzvMS zBuw040kXPUwYLp@4?^+r>$@OgPfm?AlrFGX(r?pjYwWys%U3H(<)smEg&K@A0Dll+ zc7gfmAp{D7KoE0EA!HO0X9p5M5I_)M5F`+S4go@dAP4~%AxH=^f`ma31SUXO#3Nd= zGHp$z!$q+rVBz>p@W?mdU!72i;zFo0;Rgdhn<6Q5VeI34;_ZIG70LV}-=HtO`nxRu zd)O!1<483z?u@ohS8f7JjiD;D_J5?7b2qs;w!^Vmnhgz-x(JFt_8HIwfa|V85Iz88 zxcv3046#?IM;R5%hlxyfI9+dmaYdMdeXwx9qE0kxZhw~eL6*CdhVVC$7u5T~8(NqLzTcKwT}8#71~LAZxO97l zQhxG!Ps7Y>2zMlC;$2mqO0$p6mnWSiX+DX>BE9K7zTVW6dd__{2$HuqTonw-gWy}O zvGI1SD_4GhqETto)<)wu8B4o)du7=60FonVn`WAX49uhNch58uJ%4T++}nirR*P8k zhC%jJwwop-f}Kt|pEY&Qc|wGgnhZ5aHAsZ8N%seS31u0nVqK0|gs#6&RBBU8ja+8t z!5x|hO8W+v2JIMwp5$$^q7U02m6aT)WndhWA@p6G9BFiN9RDNYh#2KK)T127|0snI zQ;u|-avU~n%#v4ND}P7Spv{AKc*o_X@knB7QX#JXc`R~Wz;oorPjIYXSTBgQB$NE?i?38bIK*M!5-TL68Ui?u? znctN}ph~XWlTgGDE7!4zv)M*p*wKuerExO=^ zSsQuEgbUf}4S(6iQ)8wP+5L4J$BWKYppHBPoaB%t9Qo+hos0xc2F%FrSDv4;X$op=~JUvB0R_!%t~(_ax|w?%=$bJPbP~}KL0Zy zVf;Lx{C~W@_eHYKkVp52JfZ>o%3Yb}0YE_`epgXEcaUL6d=QvZKG!rDVzKobXZ6$F zUP&8CdG+B}N*#4uv+(1r&xp1E?=P5c@J<0XgJY3;7iX9E7?f3U&o4 z7=^bk*PySgv?%zkn!b;C5T_K?BXAfewSrqXtCysQ#lX zEdaMbQ9lc+EU>8`r7&K>x0j4a1!KJ2A4z{4<8j5m3Xt(ko=L}I8L#F47MRR<;X`@D z6o0=v3Ts3j?}<7Q67UZ0I2M4z-LSG1NC6<64zEes9kuTgE-}%}v76n`QJ!8OaUHsw z+e-2RalrN9)33;NcYH|iaGR#}y0$0~7lmR;@qRr~iyIFP80Xgof_zk_p4=Uzxj=Iv zg_FJk6Fg%`8fRt14Gqa(qVkApL#z>;@_+vtGM7O+g$(pLOh?Vj+0tB;o3rUwSNb`d z{7qVam+Fyh!d!0^ufJxP!1Q5sVjk9XPF;c=wV?80g!p+HkBZZEtoga%psBWpshVsL@LYAqc?;L{ZW(WDG*m>=HmA5Cj+m2!aqG7y(3pAP7Q5 z2oi(kA^~ zXo{OiL80!4SQT#K27ElYjm>rY<>=~L7;=LDyQH9Z1#g)O$02A;%7M;5u7Zje$w>{~ zQN-qOPhOFo4bHt%Bjn@2sW%QSE)m)Q1p{fbzu-*+hHdBQKpI(3UqMkol?#e>5xacc z=QBNh!1vYS0`)ESJcn?6wSUqXAbkK45))4Aq;@2K26ZcgJNzLUAH9PZ8jj$B0RLt- zmJ4S`l{tocof_`tn_Bhk>=TCgc)HQk`1mL=Ho&Jw|=$X zQJRrJwyCnh)S%I1@X=TZI3CU;WXc30NqZzDgF)b|BpT4V0g}qNI&grdLrlqg%ib!( zd&%{;uUrg?#9wXsMSq3CV@k9lLn0$SR4`$FtfMvxb~b|re}RDgjHzBYPh>Awl$zvG}emm174`Zi7cO zT4=wRXumYQ;Yj;IXl69lPEh-8y)`5OU(K=Y$DojwaE|@}jE>57q={!#-&_Ocndj<- zUg}q7>VKE`6ZxI`CH2hXp6^%QWJN_VC+uXBvO@EY1{kHA?8%Qu66(r(?vE)iLG6uz zVVVMpR)kdiNscl-Ctkz&ZKQ{C}Q8>+! zGAPhbaG;+YKDMAATfDM3f2Ty8(k-HgenTz8f`2ahWdl*{+->Py$0E1hkn&@Fy!O&W z_(C(WG=?}6>A|u5BnXCS`Q=mq{K9vq(HCT1h=u$Xp!6BUA*tg{;-}ZXXv9eZgw&u^ z6z6Q=wiAqsAL~Pbe+=@HZS?Sue)ggu~u>(|eIshPKWo&JNZ>aKT3^EV|5(E?! z4AdZpQHDS$+W`_l5Ks_E5I`V61PFp42!8<=K?o3n3L$6+5CljNmca-uqNFk0cB-V4 z>z^{Z=5pN_A@}>Rf~M`&;O*6|h8E*Cdinu$pTmk)TD-|C_xnGkS?A3iNI7P1LGwdT zu%3pB*3m!oi()gHk`9RA!|v%4kpXA5x}w!8YnT0k{5RaGCS8GQy*CNXIP$|cf`2d9 z85tEz7c^E}uuGqHI|Cm#YEprYXBjIFjp==x)+%yfOrO*G}a@5VE%mzYQE( zflP>|-H-v)n%T4f9{Fbps5VI-pnsUWIC9=kK<0o=>R^VPdmr#K83U9(q*xJHe`L>u zK^!uF3XNBsjY5vRU8DMh6Diz2L4T$d(S`P+gNA4D-U@LmDwWNCg?F%+B~(14qTVk-^frEg){k!#!ELZ@{{ zuDAKZ-zQ?l`NBdsYaSo0%!O@0EJ-H%ZE$E!wC;U}D7;R0H7JNq`*ZG|;(tZell3&n zM(^m!sJ)0`y;+I)?q92rV^wly<7LdZz$m!^b1zW7daBdE7$JO3^)FuWB_&EET`yr_ zCXbMRr@6g`avmVLZVFeqU?74D@+e4^a(AT~?;LO%)!Xn#knGn}Y#-Td?+8A@UWvR@ zmBf-~(d=1~iUhvMp%hJc6@N+MwT<8KK7zuNf#q3-LHd$I`l5m*;bXiS35$YQ0$chT zz>J}QXRJ409_5x|35YwR|5F``m;@0O_&XWj5MRrq(~QmA014G z5aA?-zH|MPNFq{Z_>x@3aA9=l!g9#JUhF23?0y*n%@g9^u&o%2MPe|B>t3#Mvglag z990(Tn}N1qzL^GJ27lphm;jQiM_l=;nKx`NW2N(G3QnW^T4Im6g0tbq0*8x7+_bfd zee1()`||#V5wqx5!Gwo!JYG+H9C}s-zkhv^J?2x1kv;$hgaOfy zIBSbcd}Qo&q7io-XURPT`pX#t5+79O~Lh5w`%Au7Fh$15XSK?1~tZ zvMU}xgIS|Q66gB@29R4X^_}+)x+9(wCAU$8l0&I@fpJh*$V6adM>YHPNHv#J27he5u(kyqm6D*5fS*nBjjMo9;m z1*~!v z{@;=K=p#gG{u^gMBJXO^1kIZKE|XMY{P@eQSOvet^B4%Va(P79616;3+$6BSenp2_*I&iX`2A&44Q4rbWR-f(C1y&gLfqyXU*rXc&pN&`uL4K00hX*8jj^831!P*`k zq*Z*}yLYxa!8CDYXPA{HYc}B=JBX)!Ti)y}3ups6JQ+5S;@z7Ne)rO=B?lrBAGQH@ z;d>vwtrPvrp1Sp>!{#8!bgb3{io zg?CAYo}hB3x;J4sCCTa$`ih@4s@v<&kdSb8n4SI?aZ-XuBf7HVVOC;5Fii&5rIO$5O5KMV2S`CKoEi$ zVORt+9HK?50|MK<)rKP4s{C;X4}UfZ%W&#W3r=J_83p9$89}CNb4{>7w!fueGpA^m z$FBf_9GI~?eb9I@jFT0?7DI5+U2v^N#+=kO!6Gi6d;!mTnyV4Fa}}1TRzC@dB73uS;$r?B5mYH`li5zkhEHdg`KZ zLeo|AGRhhHR5i#OgseXxcf4^2TnaC!j%?69gRr56dF`VHQNW)v3=jldf3g+1efxHj zRv81MGjcqBAQu7xz_|11uW?EgbFh8asrb<%|;}_Gx-G*oZlR`_M6nDo}#2Bc(EaHO$UtF z>XXfoBOrvBJ$TnKSfID+g5Rp`@s?i{%T;I4Ty+(Qchy+4*lk#a?0>3>?W)h`QLgW* zXP^njjO5yhx0kDg}s!)ew})J^A!;rl@3m*qcBWviJdf;f6`q4s)@zWN50hK zc&`A1Y|~L-tHe6XGjbdQBn$l|tr1>MJ~X_wXvjeW>>-g#Gf2Su96o!re2)UUGXc8!4Qb+VW+)wSVF5@?R?|0MK(;#e4(1 zIW33bkqzM004mEXaFGuCmcperwAb;<$4l85Ks_E5Ks{?1PB2_fG0o*7=nyIB0vau z1X+XsHBqVU_D;hVz}A$mp)8yV=x!~{!l!P6>8S)9}-FO#1GqiG<) zwi{ia{NaB`5}H$FgIP92L2>a+^0;!C@!qIE%Kk+N5U&Q~NnV^@QjdYdI7h8sZOJ#Y zdHSS(x;Y1lT2)GEmu`^H2k;dhP&7&<>(j;Y6!GS%?0+IcVOZ4xS@|&hru6NptEA$b zv#^~;h=S(__+}IS#nbbz1snu&n120DBpPJl?ZMy@J-dMF`3Za-J1D{iEXp5CF4;u+ zdpe*)Nj2)2#f*fDL~Q`B7|I{PR+?Kh%T*$#m;i|1M8FwMCctaqOSu2(1ZYYh0h=pW z6d=L_Yk%Hw2PZ|f33w&#`YjOv0#U$7EKmd>bP6~_?L@#Ni^A==1!bUYfC7S#H5V{< zP2xQCD)UrtaZD&QHFC6uIc8lC3@-9qE=eo*QdV4Hcukn-z3mTOG(Ncf(c|_q%iO+` zMU*N}2M@F3pB&zgj1wco4ghMm1a^SI7yN}>2Y=LI5qhr!fUG0RNp?Vq{s?f~+5ztp zCBVZzuQPzMBf9I=10wBXVAkS7a7piCh#K|~T;;~UW=Ecz)=G`T%LuOS9u)``IJ`&z z5&Y^pRm{GG-VwEDt#Q~@ul6ILzJLIp0x#p+j^J+g&U>^AVoU&gZvxo+B|%96Jd%t3 zOn>nq5Z*otZ`+e9u*asz0QN1H)bJHT`y0s47OnLd7tXohQVC`2 zmcUVVud1R01i}b&j{(3SKi{^p_~4EvM2On&w>-W@4vk983}NBikONL<3sgk^B{QG$ zcyxiAFg3f^ESr%JvZ?@CW61~_hGN6_#aN2`4`_dUG``j-*i)HT2C%F_5|9fV0h|~B zAWdmSZh-8#@HrAt5I`UZAP7VR3IRfZCRjoc0tP_{U<4s477~$_(HI7SES2hV70*f^ zbxBxPDhI{JspiI?-~zbP1-g687T=8DlArBfD&`!RJD99h@Mv_+IcK+|KrY34EGdQ=;l7g$@{6CR%R>aG^WA-WBeh~hEVM)OjF3RgNKqDS@M!3fWNacN>S$-- zlq)L8+xWmp%%JOfcWka=*)X3+%mV?`_^^NGc^8)VMCQUJ0ok+HU7%ul&F4TGQ=rR( z4An6~&4eFOwG~ws)K=eUxyu*loBk8yiTatNO=`ItZ!Wye6Ko`tdo>vewph+n%qmjt z@I5Vx5ZGe*drWfDDcJqL;=J|~#-3B7552LDR{=Kf7bagi+?;6@eWWo~Dn*Z@mS+MVLKlsmkG-5uT0jGF8w^m)N81^%UR=oa9HgbyyQ$1WFdx>BUU`@XQV2x4 zeiBZd*M(kSu2DA>EYl+bqW29Osb3xy0*-49@+Z21k_=+_xDQDH0KkC+ju*8;8ZQav z`R6uWEc;S9HXJgG@Lv?1dp1=nGlqZcO@SinhWW-G(Wf-%FRrgP4aE}h7mHv&u-<(y z5qt_b*=VYlSpbQaFdSxw#*M^g9?w zGw7b&GFH$Eh?eU|Yw2#nQ!}fjMDQcVx1~_d;!d%rbSa6A7Tf~{{wSvSR@ zU??WiDQ>PI)Q;;JB-y6E$ryhWHtmXN2E9@!&0D-Y+03is0f?yoykTHZPL+cI_KoCeE5CjMU5dnfA1PH<; zPzWl5ghCJmM-Z6dl$OdS9jS;3pEc5x$d-mD`iN_$s<>4SOtzSH{YHOrmv&V}QIzej z*MGc;@2(y-!K<{mWD)cVyF9>SrGnzE5wBa#zslKK(aJ}+wX5nqxmaMVI6Pb)wRLFI z^pu2E3UMaXgO3GXY|OxhP+>gXU$gEPz_WVo2);MKH|r9h#vb5tW;e7QVD4>^j*)7j zHA|)eIN1g@w&Uf};tqcy8PMb8Ip9^w%gytC*VzLQOL^+1wBL8TY$;FO8ioUmy@x{Y zvZozqV9K3902x;g#+1`Gro4de7H7ssY@AHF=L)PAm?=;JeL$uq?Px1%ZG4oXrEyQuauJlnO$GlTs1D zHk=TUOti>n;%yPQXZVizTxH)ya;!@_)WFf#pL^Uz^*4}s?OAf-592ZWh4cTV?3|Sb zb1;0IBr`L-)D}iWm3Y6g&8*-;3;~;or}b&Cri1xvfU=b7M3IcV@86TYngG6Z8M~nej*SzWVU&IP=Ak#r-f&^bjIHbv<81e*(%_>#z}*5OffOOFV1m;^j}ry1R&%+=1fdwdjUmKvlgww$~6(N z`z-|38ZgN203y)qD9nwxHk2(Q<3ir#->J&KZ`KTF>eKn~VsF@-FA-))h8H(8P+S4! zeC>Zm7fJm;^&E69Ilt)w`vjG3xe+1q!baHO8ktmeK3M*-L$PN5)9!B&6H z#sEG;{p7Ns4?qT>ed2UP2FN4heA^ge%xJh!S}!7|2{;{@^Bs z7~u_W0s~qoY#4QpE91bPIrkcaJ2CFe5eD#+Fb;iF+MXxOgV77o)cv*Z%EKm8 zz=w1mm(GW_YR95c=>bD^)?Gb_Un8^yB+I*{O z^G0*`VQr4rFwsglH+IOCI7C$ixaqak0M!AyLENIFaB!G~(i#SbJwHX)UG^vK^QSP( zVOR%ame*$ZUl@p?2z>v&$4Z2va0dPUyMIyIQ2x-zra}d|Lkbiin{I=yJHi3ksmaU5 zLo$cDs%FRz_?xFxcKmrIIzX@1Hw98d6Id?b2QpSWEgO|{?ttKj$WCY{r2(a9FzH%A zy5bMxGN2S|+5Q;XwsW6BI zL6Eb?kJ%aNQC>P4TpLF7CH9HHGA8y#V0=rkWbD{yyfS(pnsPZDwP!NO&7=jrjK|){=D1Qh+j36KZkC3X_7CKn9$r)=JA7m5GjmX3k4r3Z*k^c-Z z?d5D2I&?}P0TQC{7 ztT&PtAG~Y|yXyFpjDPH)iG~SYt=M+3!;WUWW#!e%^G&wHfwn2Yg_cY;G{fPwcQK=C z=RS!%_odX;Pfx#9^|V3e(?<6x20iO2r2M|7r4z^Fj=~^;^K+%&Lx%efsSi{Z+Jt)I z`MUazz)VZIepH!hoMS`*hu8RR#iqwV zHvQy}!=9L|JbZQvdn|0H4ou!nKTqED{UlQdHdo#{YY`FG`Tf&sgL>9|ZV(6{I6R>m zhuI@?*njSerGE`{m64zUDFh4v0Cji(5~wr$%qz$=q91-ccL zt^bw3Y(c4{Y$W_EDs(`I7z?0(F9#gX>PmYb?ov&Te}5iPg}KH4UwZn=yHwyv5{N|5 zL7D?4r6@nW_59+3|2z7qz=~0CU@JOrd%IjSfBg2;rG_P7WyiUe4wlg3OwA}!HKWO2 z(`)P5gFAY4!#CyM-@oa;cgx}4V@X?{_EkII;hie3!5EBhiHPDyP;Y?& zJ|jwVi1(qja}ov!)rb&kOF~|du7xTw3l%rKR^(w_^orq8Qd;rJ`=NOx$1>Af8!7;% z6%jt>yvoc>imp>f+=*B8SL`XOSw!h_qtc}SiGMLLqLlkWPNmO%WLi!X4gdgvhj;)I zKoDRMpb#Vk9)hP5tPv;#LIFcWnj#nC`!@$lyA_avLs^-3z3s>CYTv9hay(IMHX~uKg)lwC9{_T->&J`vK3pNv zxPL0=q&xuUCEYF%4+!uA_P)wFLD1!farmxP3I7ZH(a$zrQYHq!QjksnouRr=qCQ`k0*hOxr1b?e? z$}zzCHw+TIUMB4A@ii(WU*pTL|HG7o^;DF5c2NJ85Z%jnYlB-h7)WQ9XXg?Gr0b14 zI~GA}&+ec+m7P42r04?6?~rTfWV>ngo)0jeOvU-P|wWMEpX%9BDUa)b6j zGyu!WYT7+LrVJ-g{bzhFT{(1SK^?1axGf`KkGOXBu3VPk1@?vNZx&8NyVFx93lFt0g< zH8r(8Yx`e5AKfUVHo39XCbHZ08?Dr2*9bJ$CTC}ez6aAtm`FypMn^oR`y;lAkDvsm zA6ugXJ7JnwA^8hX+XPr`lb(MUb4qWUd;qrz>wRt?={D)AZj+k7a6``9zGK~q0RWg)Uy<1dO2jk#Z(5Z7I-}UrmPY z6%@LbuXH{4$k$yEh%mJn#@cU8ILDvkCi!!~?Ep1@?yn!~T55!SFdo=#^~QFgjNu3W zIHy05bL*1vad0k*L2`e1;W=1eR^yVzS>y22i;3vB*s4q{NekikX@C|Jk(qO}n57kK zjGgj1jn85sDb_wF@+didb7z2V$!#PheGhdbx1hyg?!APQ(;+}G&!jp7ybS;_c4cXA zb#!+45>OC85Ewy#MDUCPg`go&2p9sK0O1HYB&Hj!VGN+g0!e?FX0Bt7ED)jCr@cmf z5YW1S5#BkPYC{zHNv^Ndrx(gg0CZ$qpyV@FZU{@h(7p)}@Ds)f5$%j?w>n^KcLC{E zaNZonj<9C)?6J%SBu30m6$fRj*K={8aKWpO00G=o^om_Suf_-iCI`YGml|Uuq$&qG z*@{DQg1+t|Rs(-p&w+fO1F`Z>PaG?z8+clnfOf>og)D%!?t9*9VoCl6u2|wTK3)7F z4(ie_*mzA_TgC8|YP#P9q9!~ikR_e)PhzR?Pm&bld}RAs0Ip=A z###ytUJ8K(jei7>UdyErT8b>o4xL@d1%_J?GD@)4RMvlHSw(^9Q|1>j$4Y$w`Z31= z&QGa1FOVaSm3U%pP{O7;v#``1On*vHAk(8nJn$SnN-NNHlm5EQ1XXyW?$Y}lh8sAP zz-QsLp>)&OG3xz6QOQz=N8K6AnrfX6Z!2d}4|YZ+X@J`Xs%NE?7rEf5RO%Chq8LFx zXv0RbjqHE;^w8P(zB$|@$kI^3l#r=Nn}B^ z>5ob$w?O%Pm^oEahV(usb5p2)PZLvDA?Tv^NG^YpewyeN9D+*bFKCr;v+Ic9vkB7% z@d!eDX$sG#rf@$UM9^7M^*)$~mhfkDo+lkjNNON!;S@ zFFsd(PfNqE^6|-&s3qCfOH}MQZr-k!Bz%bI`4Y=1Ni06@rGY_7EKHgd(I9SHpyXZw z)ee6Hvnc<)^qb2hpuGHRu-Wgcf5FX3xj()%)%3Xd+f2kyP5_#L4U^?`%G zW$3yEtAt~W78S(TI?jB2X7^qi-^{wg={$cZ9*6=0P(qqi=37eCRUOcJOOBr}NZFmY zMB=J6>D(y!gRDE<+zN|tRkf7F)Jlu54oxGVfh?)dAb?U1ac;~4Y+cNs8bg0^h$8IV zEBw2o3k+ZQ$QH@yFUAt<+QBvfL1Sq>8cY5quYYPR?bkz#llw$h5Bq5mIDhz-C~|)U z^bCG72&X~B7lLehyeZYx&BA7VKjMMzHM9}105qe$fda69i#~W7$9kLOW-jCCj=Bi5HXAh7`4a`S!D%1xxW%wx>g*)*(b3- zzwp)V2CT{3rHsBn;Av#O!&QGkoJ*%0ty;wJ)fg#;&nm9SvzUkF&`KkIjb$)in%h_r zbm+BO#RHT>ss487-3_Oh+KpJE7Y~HL!h8@XQDQz72o;X}WA#=384W0L8mY}Hn+nAK zu@v|rF8xKtsdTA0vGYMUSH-DOAf0X+$=>aADq1*zJRPFN=@e>lg2aEOTofkZ0|NDj z!prO4k8%o((<;tG+WMW)(QVUy^b{MbUGb@vj8p1g;LoQ=UGRyOG)|(NEXCSLn4eSz zU2czaQCftB?)ZE4cRl#&J3Q?4#b|B%&42-@`c(gXs^)~F9@fcW zmCVKt+|c%!P<>5PUwo7Aqoop`eOfAjK3Gc0--U=xwK(9TMXz?% ztqCj#04Q~GRc>K7!0gpcgJ}8*pxW$8la1P@Q{=vFYE+s84do-~`uk<*6k`bll5$|X z8cG%X7@BHe6qm7#0xy5UK1b4wavxC#a(7VfBdT_&kxuOk(Z;ZA1cjV-Y-qUN#z9tD zaqVdInX1+N4Ea1eq1T}yoaq{vJGZp1{x`^1|6ASKCLk2_Rq$#R1F_6bs>0QxQ+fGs zHSnithx}fs)#)@!n$!-3brnQOy8i|x5Os*U|CXAoB1-)R1N?sx03aZBa&CZ`=ph6r z5CuYp2#7!#CfPd@U=WZHY$1RM5CVjNOP~-`1SbS60YgGG;#mu+Ee6eBhS2sZw30*A zLPi(+ol}RD$O&XtzZb&yQsoc=)2i7~_{!ldSPo-LfN(5_ma-g%F{6PH42l24KybeF z<*z^w*AEn9<}iN=G}kY~bqT@D2hO)Qhg$?|7`{bbz41-N?COE3b0}0>z4n%!L(&5* z4?@vqDpza<~B!7+b zhd3&zI$AoO3#EZNg~Dj`z(OdxKRDHytnUTsNFc3m)aQR!dt_{qQd*h}>?Wr#n_gEJ zPMK05!+~_gM7nn_85>0~rK)BWfsBBT42q}+=_0#w(kTZz9Xf$HWRr{`ptb77EB$n3i_sqbM%nM}|Kzm>Q`vBNP71 z?bzM`4fcOwz=I4o#zmQn40s?U7d+_;Czph<+6;d=$~y2N=I2sA`Wp>A=XSB&*&;Fj z#+`u-2jJm=btru2nbQb=IZpwgM;P(WA)Bx!>1(fqji`(yfmyz&6->;nJ@ zZXUp}XqUc^0vLbvFgHm3SRxvvrH_lLV`pvTj*{z}6ZD=|_~C0t&KQ zf>q0t-5;#EsK|p^&B1i}lgpCj=2u0u!APA2j zEaE8CRO(qcTdG|;_#4ySwE<7ZUg@97D)~sJVTS4R=CFy4?NN2hYeNEP&)N^fS0aS#ER^CWNb7Z!CKlBUq5%*s!P@qmP(@KRj z$zj&yQc(Q8UZSsDqL3V(p^4-$VE|{M>m8Rr_MSe!Vn3y8rZ(It4t48i1Vq&zV%nmINjyy7dqtiUGT|B)oh zQMnV?22qYWj-Hmj5_~b#ACyD6q`ngRV@*oIu4p?E>90gFt%W7rjl@;76_F32ZEr3&n%3b4Acf3&^MQTvL!yk zF;aART9~#e_-5lbqLGS21gJVMx&XZZy#SG(`jdc4NxyHl%GYKrUD-th0=Bv4#F)0H zJxYvL3oTnjd|6-pT_G^*nO7BZ_RG5Ekrr`}ACe~w14+tHwX?C)f6K!^f9CAIEh8HIm(>d_2rwyO?3f|lw_1=!hkWeRDgGuh9=9q^wC#0n zqzTJJCN(dIPou=GHUnKHgQ2ic&`}bHhg51%ZpD?2o@AFiHDT3sftVe8Q^<&*|SP6LxKW;fF_o&Dqbv&HJjwsJJcub(`vINnO{ za(zmP2Kse$e;4<;%E!^VR`#{~vknLtTL}4SFxD66Hd0}z@j~Hg9A9P@KvePqGvUyjJ2hRhkkVVX&EQGTaUU#}1HyLL&Ybq8 zXdm60EC+Wg{Vqr#h#>rP>RIwqR{_%GCXkO1g6k>tf5!0_)ExUo4Pu^BP?kzVToDtHqH*`0LSJ^T*W=fd!@eJVckAgSajbC+_O-8* zX@FYP0g!>AdHbNrIquG>44e*x6$GO|GJp^c z1IJ(`7=wTSj{pG6Am9{mgb}a@vMCDd$+lo^fA@W0H_Rb}SkM)aUCJdUgiL519WFX0 zv>7h6kIzEGJ1@18XW@CDT1Ra{JJ+}g6+T#<4%0zqA+4RA(Jp{6{&DG2eksLCQMh(e zPlOe{SVnTU*?&6tpKsXS5&U5$Em9|$V>;*fteM3-EHt3kC~m0`C`8=5A_oag9+}pkyD0V-RNQ;DINLUhp*KvL}w+$&P)Q3@h0N z@Io2KW-7-G(G{ol&`JrGyVzbQ82*>Ucp?>#_j5N?)Xf3WO2 z)1a}53QhO9TChtS50XP~FW$+WjvfILV@Ou`y<{D$i$}wi=`E}x{nkZ}%tJhv6K_FZ z35+WRY3ITMTa%!Hp{q?k`%`r`TVwG_Vv5dTg5m&KhPjsYMc(Q(j4==$ooOlE4gt?+ z3D?wBN?c)8bWEO6;0JqMS1=fnf7+xBDN4JLVELMTl0otolR$-J4p|WFx&Rp4ROOc` zd>C_qRUm(T555xPr4M#E4*Uf4a$`?E0-D8PfTjy#p3r>T>mVRRgaQQI7COw(EjCY} zn-6>^ij;1Vm(x#I8B<5dMwxD(2hKyraFm?+-Y-j~f9_jW<88643A7k% zDEz{B4iBUA6b2B%h3H1ZU&I!(*FUs-w$9rJ#vKl1rK^<>^N_ zfX_oIRE7Y&@EmPX?4?-_$B>uDO3fYm_U3MLbguMX|` z8>4QUZ(DpXe(fs9fB2oOEs;F>8m7wXOvC;Oy8Ht@7vT2{`5R2i&t@8%B6~y+OKHiK z=mApQ`By8!a&A;xeAEaMY&oQcuw0(rCPmE_-nY32>Q!S2sWMcvdHt8<(rotA|IELG zEFIuVALWGe#CEG!!-jet!NlDCqXuo;h$9q?z`a`CdmSf(e>}R*qfai%IM^2~&Nx;Hu^33Fhf<%oB2KT+&A$F)sGmTR|naKllu^A@Z* zvnf-K1N}7Fe`ARzrv`2Orzg>KasNxaze=r=03$uszgi&%Ip|9P97wpzy;RD6j+>Dx{eSCB+Y3g&N~Dr^33sA#?#`5D|7^ zDc(f6t&D|_u;5l{!2xQ71N)lwl82FyO{8*1DGk&>e~LQ%{%0NPG{IE|G2l?#sWJVV zUPuMiCZ9MZwF``W4kpv49yp@C5?JdBp9_Z;GtGb%9Mwt0_ruHMzs56A?VZohCz!GHL)M^Gf z8PaH_*|KL!GUr>u?eRB?F_5qpDv+&_ImL_iAu+j8^ zbCSJTrqOiRNOHqtYa`I%h+~F2i@NFomBYOge?XLA#_S^1iT%+9y4{w)G^=^y%< zwZy*J@b}tQSQ|#yHx_A{{@BR_9s~=$nz!W;^o70_aal8LfwiLeyHf&RRtw~*gDRPqz)8<>a%VP zg?7doU;{?Iw(Ni})z(l7whDHjx3bW}++wu7M-iRrmLsMJ)n($r!*ja09tU{Kf43bY zejAx!GzKDWqC8q)UlRSD%K}%mHhu$W%VJ>jupI|EfE~I=W?76xJ?xro{AoHlDO>Lm z5}=EKtj8Gxl=iciC5sbCJ}L4|u1aA?EFYw#CI*XI2>caZ+aX}R6rU0mo)&I*;QejL z3l5D)9X|bIEBY`|%g{xjZ)AJsf7^gokX$@LkhBB=`V*+-TYh7W#W{4YxE%~qT_^;PBEf=JkMDX|}U6|~S1lMsW8$#sseP-!HR4Z&IDOq7@CN;qm!>1~B*e|v6`_es`C zl>zIMjLdlr<;1j5QF?hc1xZFFRp#6V|B`x5D}-^kQFF!?qFee9n5n z@njo1cPM>rk3utZdRQpDi_kL<)$*??+nS5#3R^ta&jJQ(tw&o;bnys$@)y0VH*p~9 zRX(i!O9_|OVQy`*e^@FPdml@%g{3aXw58p~o`F6Y2?DRZ1UGvkt+iuP@Ovr1I|?(C zhki;QzZ2miDhR|#Nv?UzUu7#aWESLv!$EP--6czlM(+3>GAp{>#x9Q)D^Gl;yDuO& zRf$OhV6m>^qy@iNvx;1vFAsk0Db0XiPFle%Q4-UZbV(eff9`~;))6EZjizhV5v_|= z9Ks_WS6{#(tb{_=DW?7mxU%8yd!naLH=ybs0U)c?mhHF*o4?T0=eDITHC~yox@v<; z5?+%j2lupHubhb^iJh`OB*Y#gJuku2s_et5F4m0He+l87G^0wxOMMu1jQ)P=0#cvY zuOd_m2)7gyf6;fg{Evt-)}kt{fH0tlZ>Lzmx?g(DgjTJ0#SD+FQ?J)6NTw$!wtvfd zN-=Xr+!BaSCy%pa=z?~T%sUo{r9-iC6!Ipxn2AS@DLAy1>`plQI)pS@Yp-4OMbg0# z87qGC0tx0lin3v#s{?%AzSC8Q%DoV4FU~`By6}Bje>S+V#EED8c|n_#;Ak5U`G&BZ z*>OR6XSL#$?81WKHwE2l0Zm=P#)U+mb=(7TMxtRHt_{T2l7o}+ch>!0dWJcmY(>(o zd{uvT`@+L%3r}IOWb4=Q)$xiD*yh@A48k z6Dv^%q@8q}*YIA}pLlSImbcm}{?ZziLe}+3MIX%`;y+yv@;Ex%k1?Eib7%2yZ z-<-Hdv|3gTnGBh*d%n#Vuv?7bTR5J@hLM_$JH}FB)JvC3UVBW|XqO?5PB*C6G~$Yq z?F^WtUsd*%0bE3S-rE4PMggAHa0OXPjtT67$e)1`mmm*-JaTr4WO=ZYbqQUI7eI%0}6PU<)sFHc(Jm1>t*^kdgarnPTR(pva$t2^rJL#yMK%N za=`OkJj|C$zen<@^GL%lZb(C*4R^kNSr<3Blx_dH#>m58iReL77@HaCM4oG$oqx8u z5#as9Vb7ISx>of-Qbdv)vfeaOBuqEGexawwxwN<8Gh*%z%C_RK@R;cQ1}CvvK~chRhgD|eN8QkFV4e9_763fhs|$f7hUd>AL22s*O!8mjPW@x&ZlB!6q2%*?dcO7PT@p9Pa~K-;ka`oobgsl3w> zJ*Io?N#%fY)`MtounsBKxJZvysMY>i4P4rExc(sD#}_?$bUB%Le@h)nY!7HGCj5#8 zVu5gc& zp@{rH!5NUu%tEbH}LK$DwE(XhP_(keM$ivwSf+sbds34$_fGZrq52Hf~LXB)QSP zPLZq222ENizSqkXkeV!Hk`$suI02BYxqAX3kq~dbSs*Xp|w<0I`09(|jF{<6JTa={@buGn4TUTr|Bm z4#-oe`KJOmIx8JV(-s>XiC5P{GR^j; z^6B@$f5QInbv~ASki6*^%LK8ocH3d9a#v(2?EKuq>n3EdO`z}&BV>6Dyn@yhdT4@(je~I_)hif zl=P2Rovd6E{$AgDxFEXo2JkKw;47c^Ogqa~e>YrK1RJ3?NU6?JuG1J}@?g_Ll_WBrGXbf>UkX@HF7qMT3 zG*{Cd#2qj%9|`UIJ(& zf2zPV)v-9U{24p19z)E$K)Ii=lmxCkIcD6;HR zCu9HT1}L1lPwx&kZudSMEXwvjf3l6dhP`+AxybZvqbtly^TuoS&QbaGT~2VH(_?d& zsW9*BDN#D4o+X3SDQUEFBq|0_J>sL4g4ooBC};wMARkCfO>#3V*&0WTUtFQ`)YYZ{ z3GE|HAv3t1R^sXXaO+YwAy>>g)cJ_s%%m6f|VbNi&GNOd)lzP8i^Go#ZjrJpV*7j#Xk+y4WglC??{-|Ixvn z_t%yK!2tEAk^|GBeMgcOfADeqHFW)*!2PwXYBHh$3gC=l1oF~W)FLnHVe1sxDV&P- zL?9&YphX}4Bi>oN{bcmYf3uJe77|V4s3?ZVDTrjP%PYIU+kUEo4dMiBfiVD`^C?mK z-u8PR{u?v{=MMRGRk9LXLyQ|s?Q#~lXsFHTydosPZg2O&pK|zcx=H@~4lkBZH{!Yg zMXvy`pE*j~@;!k ziH?*6^lILUy!A3j1GRJ-zz>UFO2!SL-Y*DJnG4*5*mc1vf3CDk=(+*`k6kQ!iI_Xp zf`Rw&{|O5GU?f#tE;*-SoLmfd&(vcTv^*R%_^Xa$?b?^kn-R4L>A|~zz_$W`>!pI}0!j|TK9$#X( zz@m{>TGGm`e^HEn@WzC|v1vrI+zTDTmn@KbD+DIfxYx(v>JwVrRj|p(OKy3{u zPinm8MH9JOPV?$6r5m2qFZbi-C@SWbL%&p}bbv!f(SycY9ayV4MYGH2MmPz+Zdaq- z#-oir6P>ed7jF{(f zzICJkuL8y;>Bi1nAwYvsqn6BH?Zu61g zvia^e=m8za=d|cakZWv74bQ?@VWrvGNlWY5UYm=qu#G>Kl0pG&Q>Ojx@R7Siyu#FsIbn zK=@17@DR^7%o|Tb)8`qn@ctGxyRuNl!dVN=Fu8+a8Po*cc3uf9@^n~m@(;y|nl?+2D0AJOJ)6RCMjjoMWRvBQXE5bVT zyYTIIfZ?f7a@vOl(|+DsIke~oS?r61CR2znSkhv#~s4MpM;XuA*Q z4Tc`)%N5ZiGww}69+|noQId-Se>WzLF_XozGKW{IM$xt8XeF!XnWT0l+W%UYXCV&l z$Ia&2$Mg|HFN4X#MjV}kE}!Dt5z0@O(1>@o`)ae#Rl9JML95*1yi|2|ufz}Svn}Ef z+$T!-U{YiVx`+cnZh%fQ3nH+@qa4!QR1#(gK8S3SjiaAW56xYn02)wdf2jW4ynvy% z)?K)S?eqJL_}>zF3g|Qc7F?&p^8ni8z%c}cG`Y06gC>lDFk|%}mABDGhfgdHluj~G z?4Su*)kGXL4;3uu-VP%c%yZ4icKiCjm+@3)Fkb=wsxqPcebm8+I!qX|qW=hBp+AY! zOBLKt5V9$5qRA+Yq}hndfAWjey8XFr9g0*qxfY;bf8H^wc1DS#N}>R!f+kXRbO-Xt48qUFN!N-msB2s^q?~w(V%ZI`rC<_Zzy(mX%v(CI z!YdiUp;5U2uJlB@gSFu5@&y_rNk%Opckg@2B5>=e-Ze4%a4v^XE`Xa2h(pM-a0s1E zdIT}LyMY*;U+_yVe+`e@CZKVSgsd7^BEQPl4Yj zOP<(nioMYfWxjILV^xK%&FuG6*Pxq4#FiLUn7VaJg}I$I9xOtwx;EOZ_j?>P<3l%2 z4tpSMk(s}0f`9jbq8~C~vb2!ljE(H}nDOorqeSFJSS4Zz?0JAn`9{)Bfm9uH*#vHZ zOHUB6&akJTe?|9m^@q|VCMkAo{MWW$qyC^dPVwXJ!sz_^#Tr0!0p^L|SAB?vXk%=9 zb_**_c#{<^nvWA>=bV%PkocLxKs0a<8{w4Wc&scd2QacbunDHN^R0Gf4DqPP7q?{2`#I`EXX^t(*O2t z@|T0-A1un3#bdw65x2Rm*xc;}sqlv8r+l_gQ3v(BbylsA*>*nzPS^dUH#c#cqxs?8idi}dgqtu z2bLA_f0N2>$3%j^k@BuYpKQ3InCY=8*V8sI{CE%1&f}iJO@t2Ae%t24R}YS9%x2?* ziP)tH>Sb(U67=}2tKCKwbZY>1^e@81OOM`-kF%PN$F7a&+V2Kzw_FLLw?FEQ^J9IM4NJC51e>tL6Z4ocO}U z>oH|1h%ReZvFSlB`ZZ!fc){R;^nur^J^(MG>~W`jR8K zf0aRbieVXFg?a<X*0(tfAnEj-nufazI9|=MU<|^?i6DmVGQ1q*+pg7 zsRjCU0y|utGHz8b7yCNgSnz;ym5XlMz$#h(uLz`T<4bZXP`Br&x&!6MU{-}ALtrL^ z(^#A}!%1TT0qR|B3OStu9vzddLEP7x9{ol-Q@ZD(5U;!~C%~f<>VwBMP-x_Df86w$ zJG>j4JNEho#=p(0@JHe@Y4KsMDe=4G>r}0XV@Dm?($X~}RI{-0-{D*8RWwbg-&{-m@Y z^Q#FiI@>g>Jd`$ZFtF9(f4L2v1#*e~UkR?!+Iko1jkelSDFoxb4@r#ooALP=>#qQ& zm*5y^2S0Sh0mt`ZvjIjCAT93@VFu_U{dd~b{S$q?`LV! zLLTP5^sFwDEp56+ZLR2VdHMYHV&29JipP}|TpwL$i9i%8#P3(PfAzMgmO|VGSzp!Z zTwFPJHh8GZJAj6$LD197D|drIcrB@NUe~R`6KGYDfj#wY&)7ApYIdm~6_W>^HNJB8 zv%aQ+;dW~oE!YXoT!BE`p5(0_<&hA~$>FYb-z0iV)R{IqKHvjA^2^qulkf>u4w{-< za!BvGqRwft^vfn8f5yV4(LsRDp6ggHkS%ZQlVW2XzsDLNzXGxC9tm8abHl#e)WY3o zk>?wDl?$~&KV0*~F3=!dusVqb0%HYpOu)v1{s1^bkdU=4i2hn%=W+ud)KWwYQ>$DGNDB)v7XpAM9_wP zrYC5pQjXbe8;&U=>{M3n*l=n32Ype>M$*>ODw39Ek#%n+ci!2^s}iZQ68YkyBX+X1 zDW9DRSTtJRf1`4wTp-oPA7*J%sAYe<>$bMO@kH61oe(D0R*wBEz%ofiIVL}69M!CO z$ph8qSx(MF6NMZ8p)*>%pgE9!3E6)6W~V7222?=6vSe2YS~|2f4|0?IfY?1mMD={e7Yu{ zf4G;J%id6k8SKe$GMg9LkMMX5`&#)LiGx;~S=9Z#SxuRe$mGmk0m5`z`15wyUUyDrM?6siq_kkV*F=#o9De?()S;ppPDs^c=DxtkOt1`11g@e*Q ze=YW~Uv@^;?P!ygFF~7Na0d@jfeLzfQU#_}C(az|U#t?+C4N$E*vnAQJOHk75Ev)d z7*?;FkF?@5NIFG8*M! zeK|`vcea$`^Ioz`ntC6W+f_M&@SFApfBiaSD(ag+?FNM#Ps2pTrNa@C-&vHpe3MojCjokNuAfh`k?1K3+?Ygfap5mT1P%nZiDy zQjg>`<9Bud)IZJqLbZ81YN%f8mL5qj{ys#p(yhM3HMaFMhtb9L9M89^6NY;r?oE;Z z4q0t8z7-l&<5+oxFBFLbYSF5*b|Jd`C>Jz;h*e;FA!2~vSQs*qNttCre*+){VVh~O z>4Mm$6HDGhv|mD}GotO0Jq9KE`g=#`r@MioTkReWIHO@Ap00*o0l;8JYP3K0Q)A+) zTj@-!=t0QtQ!i1NQL^NF2WZH2o`%xm;-&in@_0>MgDF$vQ#}2Op0038e?mmFgBJg?hO?ZD+p0Ykv~w~1`4OCkNI6=Xqwwf?gZluB z679@*3RXyFnXiZ+B^c$vew48Ax1R$ulYllc&r-HauB6d$<4y36kBiu_k#5NXm9?nM9XQM4tN2H_CN(GZem;E9sMI#t z&BVL0Zk`M4f5lcUMDw^Z@PsLt&|%~FYYo;_9uB3EQ|J4!M;_DziX@*a0yJBre?bI&)d<0$r1p;EzXRu+~e5w5%fu6jc3&^RTD zL_BG7w^0#=5XHWwdhYd=><#hPXZ~^VbE8&cVE&?0$#^Gme6phDsM(qX7R9HoF;gCM z$YpK73l8eKi`Bso3jC?zl3 z(*%R%fAd8YEvcktSq;>(nwuoIy$ z=pw3DYC_EMAYeIfV!A~jf3JF6@^Ye+${(FPj>8a8FiQA_8i-lQbU1f^dSw^FFCfzV zF{Q>x6Ah`NYR8e4eB4{=*_m#Yky1qw#(e@Pf6XgsMAHq0nB$QUNzC>4b9yg8FlY=M z6sUZDx;|y)(!Va1Y=T1E`vji$x ziy&!UmP^tUAn}(0Xy}pS^_5HPU*LLLDALL1XF!`91$(N1N4gI0VW?{tR!J>PAcL!E ze-e)kx?R`KlHB_fo6hMC(2vFf_v#)6wlI?FsQ8ER4-D(CM6nAPtWYv+_*~#D)MHrY z0tFnCocd|9bFJA5fQH><`r;|k(ylC*J4Bf8NHaj=3A}L}9v1aEx@Cw!2Gh9>9KmVB z6re2U;}ixn6`-B$K`n~_s8z!&xwr!zf8ucNlv95!X>z5B_Vindk8Q&=YM?9FC8*BVIimjH+so&=B|3pJiG-jwd>g75JX$%*0r7ep_sU4REk? zUu6-1=o!)T05CU%fe1hqAd5?rq_hhl(HMa$OpP zJ1`sA;;oy@8LeI%moE3nU}9^2B%UupVRi?fx0D6-^g`|$+eJUY&DUb6t0C7HST@1L z2V2W5*RKcngkwe{9cYN~=iK@9d3EABmWSFj;76~bZ)0iAj!gd2K&^0U!a->0U-o#z2)pQ zQYzN3;Xoq)zdSGlWn#MGt0lEsArlY2GO^7*)=UUroastfE;6xq$s&efH1fncQ2+d~kv~ZbBhYg2 zk;+5@q$X;t4eb>a^SBHkn5-?cP}aA?i}%yBed?QWR2(#KHEsdSb~<%C$WwsR0a2XGH7p6NV0Khh#iQD2a%z;>ZSi z`X^+b>F93=60cL|f1p@xc&-iE6yOng!d87J^Hg+4>SdYkQS(e`LgdPdI6qC&KEW93 zZm%%guvQ=-=7NfPPC;o8YlQHShKv;b$X?yz%@VdggMEjqX9~iBW3X`1t@QK{gNeGG z5kT#4%(>kqw<2~8#gNXBCRm$;BzxgCs-E1?mG9y1V6><>7kB)x|k zgXIY|!U3T0e_GWW7HAD_Rb@4IeixTeF|Lke=<*jyJiQF(W?;aMnhj!Vs05*r&Q+j& z%*GO|4HFiy^1?T^PjVeiBR6`u1N`Vf(AI+P9_271qvk6#{Pa1tufpWUy6{(ZNdtjH z3oJ`yn;DAbBqJ>}S%2}QIXyVMEzC(YOJGS@G*~_je^2=W+god>9{ap0ASA+pwab(H zL%UPooh?lofziP0>0nHvD@0MEtm2qwFmm8R12YCM*n3UDmifN1=EI@n(-PROOb~tn z^dBqp=XX_yd&yCtC{_Ww3Otpm*=qzVC%dTkGUpq`&tJVf)xr!$eCOZ4QR`w-;0k}n z#I|Jpe_31K&fh|;K~$$ZvG%!1mL;Kp(DS6JCpL-6BVYU3j$7 zte5D8Tk^4JFtGZOa0nejAgtNI=to5ip~(drcxEcp_bT$n5m;U^h+<;O%D{OVxG1XD zO}GA9)y~TRU4Og>N+tfhQRf&pBaQO)dJ0_9f2E2bQ?x*U1EV|$`hba`uCnT9>=Xf( z*>3nxVD(W-xR4;=zFVFwHHfol>oOBcNYVd36btuds_cXD86GKp>+zf+5)Uft4Z z$7>CReCZeU=Obxf8w2*C=M}M^bMx63eVsHW)A#}P@~{8N-$y%J>6l$LzOZ)cD%uf} zN@YD3dex;fM}28!1%1+5x~IxAJb+Jn3b=-JJH_h}338)r?h+VN*iB>O!l)9XHBjp1 zngK@0Qm(Wx;*QL0Db1Cv-ksjHi03ggHO`fD6h=C9X)I9`1 z0D}R5g#mzr0RRAvfdhgAf(U|71_%Q}g79KE5J(4yKw1D2G;tUe;%g#J(bB!o3nUvd zjDc3VPD^J!vnKt3Ft4_k7typiOOercfdun6*(u=G|L|!K_z~6suLz)w=(9+00@?$A zL2n$VV*=_1#;h6Wio^6$q)|8 zQiJ3bzfdCuO~Wz>Q$r+K$|BNpng!@F4RQ*TENENgvU;wdQhD61ORfYoQl3A43nIsb zbhXXSHO1>FNK#LtkGs%miCuz^^elQT|48Bu2zUL3D&0m~vtEfT+&un6VkIem2y;^H zbA;6uAW}gSG9;tf)u|J$N%5H5`@sP$u{c2zN&7%*<5=^b*FBAs9sr7eml zt)BY|IkA|D>15c$M1 zf=qce%!HZ^zlrHgTJm8>A_&Bfv6Db^%FLW$fzDj#NHcSCzr!R3Z3B#? z8RsB5VG(w)w`@wHaO8-PS>!h=(Z7N$W$ZW`D2;Dxj6{)Q1O8&fxwlXR_y)p6qH1N5 z305TZSzF+c=thbT^4R%Rj%62&O75~(ULlZ(OQ?WS6NaLH^4sq+_f@?HhND{j*?H;_ zEO~xLSfXx88G|+NtwmdK9gSrvg^M*Fw-gSm56HXEgp!$f%dAl?&GJ|yPuXOZg!O;A zw(tW>qZCAaYS9hwZL8V-)KG!E#%M*?6^)^-9Z`{8LV^?#g8ddzL?2*5`0N6E9NYg8 zi=g0+XCiZdj4PNP^>3oruNq693f~2ZD@O}AAsXc|Lh-*PeiY~{(Fwfs19yD#WvV~l zzBb_7h10w%|LQE|1+?JDE`j$O*oUR8{!=c6-fGNth%N(FQCce0jSP=N#2py4Oqtn1 zszM<|m(u2a{oS5y6{2rqIzs%T)jl{QPX$~2qK$ojCz5ucP!a0PWnsh!aJmKgD?D!6 z5uN^(G`KENP#H}I%uY}}B_hQ@&x8(c_UfjmwIEU__-c=GzcbMzD9sbb-@r(Eqlrnd zaNu&tkbP6U870&VjS(&CZjoP_q1BWVw~bq}O$Z1y$lzI)q)ZsA3qds~(3{fxOO~(s zFOKSeEa$TnZh+|K(_O8GBO>l3KWiu6DH=bAkY-sjgnyS3NZftn)d!jag{q9GPIL}K zA2?093>m^L!loK7ACkMjKP+uA#xX=ez&cTswlhWNPjJkmy=GH8!MJ$&?3oB$BX>p~ zu{>g^C&mvN)@nD!F6UDU{AHDGd>K zL5Xxg@2U<3*S6maNw5Ka+%0+w1dl*NLWGX>p2h}3&`3ql5sDPK6;hibo7FcYL>x4K zLW(r7j7-=O#H?|E80+c}L ze`-H`53M@;L**?78)S@E0;)fem{5$@`yIeak%DcmY;%_eXb|cj+sYIoD)=%~-48di zfe}(dm_R-QPq;O!9*Tik0;>kAne7HV2&}IM%*cR<7jm`z6prJ=fg;2EQZZgh#Mc9# zTqmpme=w{zrFcySSJYjIT?<<`D2)Kse-?4&uVMD7YJIUS!Gz}mX1C;91vdz9yri0nDp}?`6hW$5tf7dq8 zezc%sX&6uv_O6HCGK9+a6dI>JR5`$f;50s4GH;|Gz*S2uVN6s zv4i0rLN%N@v|%~?jn^x5FJGwilS5UcgS8cQp*DK3jfHZ{Zru~jz*kGj0GCrF!Sq+$ zMS*}2DUZpd=1HDzD)bwiVpP(}e<}IlhH%Pwi!5Vk;fyM~1ulE}Ssx}|*M#?uYl?JG zt@{V&a{nGzr`T^CPOA=f(_lw|a(*jgez*gTIjM_h35I9U2JrM=eW^FQO zGI64zMhdOx|F()%BO$CjLH~C*A~wU9QxWw1(Z3+L^cXJ@QXtU~8F^2@e*!BeH9rBW zy1m0#1$??(Zi4`0sW6h^z*>}5e}ol5DL<%nZUUpEL5r%uQ_m`bRPg=9Kuq*eDh@%L zED%>7Il$n^ND_qz{4dKmvUJjU22zho?*!K8qBG*CMC)m^LueE@M=WXD?Uti-GK_LV zb_S2}tzeBo=|^Zv3F0She=7iyM<0SK{*DkIe$n#XC}l<|;U_lV9mdqq>U*$rFB*ux1X3`kBH-(rx}4@eds|l782YcqYq|i2g!*CHR5JnwtYdbvTv5IJ5pF z28-i6m4(cx?C?UH*;Dkz6O0d&#>9DuzLOiR=JlZv)0I&Un6h>9fB6zL{p+hQB~Jj* zAn5oxK~n6I9;fSol2Cx$oDwYu77;d(GOC5~#|1Xkxdd)z}|^0>OIH=9i0JkfX<3s=VQ$to$! zo^Vl0F+>As5aZK|R214cMuPD5&Hxn4aT!Z^lCLn_{s1g})$5@b8tnZ)FP-XojfYnB zxeknuTJQx$f3GN&YEAX{(sG@SWp42)TSZItrF`5j4?P{a1GGf~9RULXhNIpGJEGdH zr?$^FYXK82QN5%@T8fa2k`!O%uHdZ8c~HXNfd)9E(>c2WK=MA?oX+gx^+F)GoQ2k+ zioA0LBd=gO9WLOi!czqXqTH*c9luF*0#HA$T>Lzvf5(?7he2mT2AqhU9zu*usVT1H zLIvt+gXBdfLr!|3fC8}_LItKQ2mLZl`yC3up|17>-XD!({)0<1)g+Z0lI2oT%qYD^ z{|inzP{w44=h9(upijt8gBi>;%}`D-%Nvs;LxG+5wk6AK1X7qG$zqjS0*lQf2hk9g z9ZAXFe;MIS;cz7LyDAV-M3>1=Wfdlhch9h5ai3L0GV^DDg~=&m$dX}5eImE4$bL$` zOpJpdJ|Qf0-`^vK-sP|#4DlBI_j!;a_RX&gx z`JH{*^EO)@Rf=sI!p160BRuU{?V0O-C|kQ;X6F1}D^LbTWZ~@_CiGkmZo%am}fTqhs2&hEtZxt}uD{N$1=evqg%8 zf3;bOIa;8p!w(QxcHP8J!|No(c-%U+Ppz;30y8;%Ne2B4BR`jS+QxK<=EN3D1X$WgorG6=Y4ep-OP@_X%snnA`8okv0{?yoEcslIv% zIsMpYE~B|YP(q-zhti+#WfaV+aHf-pf8G28;@TiH!4TY0!W~=n{O|#2=8E$55b}S) zcXzPG5NjoS9WfC&o(`B*!-sn@HH~DN0z-%LiwZh~o(gf~Y9uI*LGs%B@}_)^^VN^~ z#{4XR%o~5(PkTQN!j3fAW04 z!cuPeIY^#ECzJz3MhZ%UPEQ`f(Di55qrG8n1>R;$0huF)xXn*R^ONwUS}EhdC1lVB zuIGP>qr)lQK-rLV9VxZ`-*v>yjA~nOEA6M%&SJplPF5|-Lx7B^o{1UqKpT^@%c+3J z;(dFcqU);iVJJ$ZvQ;`6O%UHDjkT-SVyaNDcb;E7aLB*3v^eZcO;<0MCQ$me+%EpO$)xE ztmPiJMryChj%LT40Yd-`&hIx$GR9JQzOBkk-Az5mfY32p8F|}rnDF1AnVV`*T!2r^ zl2H`MI;9g9H$V*zGhhi5;-B?QOrsZ9Q%PV@?{;ELP-(z&%m@g*EV=H;_w-n+8E+B) zZ=4MLR8P!PO9iD-|ocz!(=27lW#k5vEIy-2um0EIG^zLxj z%oc0)g1MFEeWOm57B8MhH4rG^XVJ34hKT2dKY2hYnG_ZVSSL1A*bbi@xbwse33#>u z#k{TUhNTe3X(O;_1JBb6VJdfJJOPi>Ua+|n?aG{re;fdG$uVH;LCWEPDtO}wBZ?yR z)s?!@uqZ6A7KNpAC5k{vp643nZ%{T*8McHw2#^HJ^J1qca{fq#FhzUyo|WRA1JsHD z(h8Gs`jCGhh9#&Ie>BOm8B-u22tbhVP~mVek4S?Q5t1JxP{3d?NDKnTfFO7X3WI@w zAP5Nt0)ZfaAq+x77{Wvllc|pK0Z1Qp1TQ3VW=U;dBv+`sq+&v*9*Jm<)V$zkvaJwp z6jlNp+@3#oTR#2KVEoh78JZ3s{qEh3dEMicW|V&{xl9mT-l=ryEw@w#raQ4;kcxqJ zW`dDT=5xigKRae$xfd$Md@H=C|H7=U3<;YG%(yV*-`jlof-BPtFQNO&l6WrtsEYS@ zQiTqNQY9z`f6p<9UYz7~Hu;{xVU9YZ!)~FE@D-!sdjJn>&lcgIr(EHo?TtJW+EYfC z=7fJ)LCu6)7RfQqE>Z2jldyQ|A0}b_B8c&>31K>{JIKe?1cY28yH+)IeKFz2D;_>_ zk)fJ|&2TsTGHG(R{Qx#~b*n-``uTPOrLN88nnFGNyb@-TRo8WHt(mLd{P(+LY51D< zVwp7?;RUlQVhuNyHcztQwseo~7zM&nb|q zewLyQqE>6Kh^oZD?Xe0@oh7vvVbqDYH8$Q|CMJ;1c;0*xqR`#K9ekm&4E8o^CzXmY z0Tx!y6-?{t+|C5h`C!^6G61I&{v$0;t;zM6=3yN6aLk6((Yaci>9I-}r!!4z7B9V? zxJR?wZ?s2WIXc**a-|yU(JXwZN1uO>s1=VcjBY@W-qwH345JiveZa0ZA;_Z$)Nv;1 z--(gJGwq>$NnH=8<;0t{Z5#c=U5fGo4Q8Q}pl$%QP^DMuu;UgNsiGA3S(MJau?r+X zz^an8M6x8UApC=Zs)ZDu&mv_lDRn+t%fcfsGUeL@ovu#7kIt+VR$^;dI5K}x+NcCl z+cD}71WF4{eD+8J!9AKP2Ra~ij?ehd)i6Bb@|H*JRk4oY8fWK6{!HMk8QpabRzkc0 zPW5l+kfhBQj5C*Mo>mg2rymA^M|>#e5WOU9mB+jqfN!ZeKSBmhah^>r>jM zV!zYE_TTANo!|}i88S3ZLxk}q43sCzl7hvlSd4HZ3uH~jt(yY%Wjth4IO&t8x8Ag+ zEyl7?X;Aut)p5my%QwxW6xS@ZBHoFie(#25x zmO3j03xSv}dWw-pPi?>bG?Jdy2dy0Np%fr2E(2!}T1lTKV-pf$o7 zoXUY#ak+Ndo%!T!#+hzy!MiM3l}L3hS|L%c`F{D)ILjNTgx%l8Wi^__xZWfbIyemn zy?#jv5>#IWB1xZeSNnhA5*E5ue()?**$^|gYskd@p&B<+UA2B7j8!~}`pdr|$L2kYRgAIuzSngfHri^y6rqGM^WjGKcy2Yvtyq`8J%&k1kSgg|-_}9w0Z9}Tro_U55 z)(eYf_zyO{NJ@X^C50j*o0^dB^vb|;fyp|8>@ulo_Q7HU$6^0svw%sxy|^C5#=)lyrJu81tq9k=6U${)+opwF?5s+a0*%;fsAigYf2cZiNvR!6bGa4`zM z9OyWLh^w+!&0n5U)%&X@Sq1S(rLQD=ACK3vdL4f}j&6U{K~xY1a*ue)ozrmEg@5ztL7O&R5??vFgbF$`tlrCilu-0#?o48tg-miP{n{x+J`s}E}6@pRFn+7{;qig14t5*I1VCB~-d?gx~iBpNCdPfVu< zbwde>bls~ANI0+JQ{*OnU#C_B^`o3)Yu~~jnkW_1FR)rnDNobO`(s$=Pu`qzG94i% zm6Y$=E!a@(XBHVj_N*pz7R8Dbzrq*>xyurR#ACMQPNJHlp!um$*rE}-1(^u2&eI#9 z_w;|7Dmp8{1b+Fq=Rv_e3vu3JFj>pJ%Cw@BF-QUpk}x5Ok-IhN7VKppD=@t>0A)A()nYf)RqPUGDo+Da=ek?16nDI%9j_rV{ix$joH5F)X{8 z3Zj&o*JpW74>yTw%BX?6+xC@m+`iw`?K^+#Zt?aN85LWD62i@PIJWSv2OrJvnGCKv zANZM!x`uPhM-G_sxjO+SEEM3FEd}^YD!_3+Ij{mu0F)17M{jJ!fE+dBSK2FsX9Uw; zuDi^$_3BKF2_4JC$Wwh$dK+I)PraKw;A<{Sbhs|emzcT}b`I5W=U}o{K(3XuSL=U| z>?xawC&U1v=tJfW5z5ybpb96EV(nEs9a?)cYI$I+cg95PBWJJU_q5m9i&!rA&h;{6 zM}N5Kd4VQurMvUwR;VF`N*x;Ygb3u~*gS{G7dI67Dv6P=lK!>yM!v}S$k$BL#R;=4 zCyqCfDtMFjEW-)pNWM5F#WgbP4^)2-FhPrpp~q*i(u|-!ixOif?mI`SVGv@gXFmx9 z?wGi%aXGHuDJ~1W$?t$YD5T_GpX#L!k4Gz#E-MF$m%IznwI44;eG?a1jz~t= zl9zz?k;xNgGPwGy)xjGkTW=uIO0*QYNhV=&) zAjX;yGLRO_L{EA?dDfG~9%MqxN|hFDrcr1%)`({;?l3|dr|_9KNMumAIjByE7CSy5Q!|}_Rf>Nh5~hC2dehVaIl-nYM)Os+#hvxkDG!o()zkzC_g3x%RYaZfYx=taMv~+ zf%Ji8hl(vic>^V(JXMJ=PZrn?CKHs8Ga;NxGcVjqspFkF^D%{d7Pn1yA9K2;8bo1uK{m=g%&Ai+Wi7}X3a@K|PFH-k(ZT~Q{~4ZtZ# z`iPu%f2#L%hfL1WA>@CakbP>vR8w*~AZW_BM4EDiQfI0_%1KpJ)d+HY$~t6o>VX=TO(ShxFP!tA`L%;xx^yOw|IoP#7USx za0zo!bpTV^WGPhgutK$KGZqgfI_|B@Km-I#y$6Rf?R)qUfA41`CZ4%HQ=8TLYewbNSr*{0Q=qmB2X0W&qB9>}8AeUx7O)-;FPjF-_^=dtl8R}QW6^zGx#i^^6j zkaIcCn3TvEWUhaPVA3@n!9*V}!Q>9vf5-_Y(V7%Y?D}nK+k`kULa~OPi~^70_ZSM( z3eN#2T9cvP@TJ}=Xc4IW;t+-1UC_m{P}@Kqrvw;ma*1&2`akNVkAlYgEtG)-@b2sY z{^s7Sz+qh`b>R|Gf*b#Csm=nkkBgPB$YX9w4)tvE}2 z!J9SVg%N+wX~L&IvlQ~UA$akW6h+f=eF^L{@W?y8Ncr6*V`~*ZrT&bp|UzGw}$0-Vme_tW9x^)1$BQUQPlS)tepWQq|t};kPV}7O34G@ zgVTTf;gc@^SAzXY1L_ag5F^kP9Dc&9+h>vU@)hnwj#5m_TfrP3uMy7`3iKw12q0XD z_W5lr7GQfzG#{91_HEko9h=X0YxjO$TV~X@^45?RC!qQ1QbpkGfr%!Y`K&x(Sh23~ zMHd#g6e9(qw?_?7(u98A6HKg`wv}Ze*x-M06q1{+#AySi;m#g*{hVCU$3LT!XE-$( zm#cB7`lPTi1Nr`lI)V*`4H5+&B5l)&BDSmcpK%2ChTy>%vgv_ zw2s4E4u$?aQ&%X&EfTWxrD4Xw7j%L6FP`8|;H!d;BSJsRH6gc9HX$8vmzfb26c0FF z>}>^S^BNd3Z5K`-Xezcp5B}uH{xN^eNXI8`PiU9Ao!T>q6lX0j{O(MeP>2ZH0?&1fh%-Icq)I71omnfBB_1JLC~_I#&DTM( zAE6I}ed`2We7ZO6uhX3`z@G$wJwO2f>K3q$QPx0o9Y4tJFzBB^u^I&g*8OlLlzUO~ z9AUg*wZgmTY*%-cJ9$+P!JB_Do-3_+B@-Z_!L)Z_H@`~fD90G`!(u`}T3*m^(Jsdg z@(O^PQ$jRlI;Ocpjk0i} zM_@q}UV)tM+7T!L73hSBL;w^5G|~Wa${`tGc>_EnHf>hIP@=N^x1)c{T>wX}sHh;g z6@Y#3DS%Ay=_{Vxt=4|Q{nq}B{OvbSWq!R|wm8$6n|70C7!O@7t^MWyufDnWj&Wb@ zYq1Rz;n!|T$>07}J=FpnHivJ2^on(pGL{u@#?g#k51I;G2u&@ri3cRbrI61NNhK7;b=sbV?P9-fCPgA4TAxQfr5b*fCPX7 z00RIc00IDk0RRX90RV=B0zd}=p@Pt`^Sc8cL399}$F(D32Y>Lyf>Y5G&M)`;5eM^^ zMgiyXJMs`ng1v_iI3%ixL*nj0_CVt0z+-oz(d`yG2gC45a)y5&(dpH{`Z8+#yik}> zVu#leyE7UJuB$;WP^rtXYNX#oEH+ze;RV-;2YL<&_`mK<1OgX1T3!VI%0is&RTk2AN=I3bGE@|6eQY=1OhiB51ZRd)#F*d%DG`55GT9`$$ULNrgj#!j&?17xJ5PT;AJGP_(34ORg<;~bWszcp zlDSglD6_{s(mVFjK&W70c-_;%yH_iDt@HLW$FTLPl-5vVqasYtX{2UQOVDf;qFCZ1 z$}JVq!WqlH>a~`@F;X{@KHiz7NAENgv~hOJCYuOAhqEu0Scc1fUFvOE8edlx10Ow;ijdz)CkndNMBqhmd5D8oPd4#SbHFWyrS;WC20VKf?f+~d}D0EW3Xc>4vy~6jCz5#zS%fl={ zFl*N2E2ev@YhHbtJf0@>@zkS0p4dD{b8w+C$$T@rps$z|PSrmd9h8a)8XN^NvL|gI zD8_j@!YVr?KAu1k=?NaRmqr{9y*l3M3$1@Ame~E2<~(dI8r{`?y&(iSp}pb#=ZObk z4a9FPU^kIV2l9&;t4QB5!ru_;&6&4EM3tA^cGEmUJyPrg+%ATXlX)DwkCwT zl0mk3W@U?Ix{PF*s3zJl#Y7#*{QRmP8( z!~*6g$q}-xcL0^4Qn6~TZG(dKU+#xf@67QuofQx{mgT)9O0?8wup*2;E5ulbs;OKn z6~+J~bhUTRlTCosEuMmz(RR%)SZvKrE08EMYv9ssgI$6YrI}peNl_4 z{awpV9Q^SKhX7x3LBh{}la5otyzaKkSHUCfE^k_^W&KYK5{7B>1g?Lk(kpV`WEhgi zAgd+Z3e>|xJB$g4Q>!o0fgJzk*4~sp=eRx4hS>>?Xby?ApEyXFWl_Y7TM?%}fLeF; zQlm=|55ea?e_?FnE%n|Phb+x#b9i(`uV}Jr4#nw@( z*vh>xRL5d#csqi6FT;Pg(mq=N|Fb2^KwBj_Xp6Chwpgct6ppAy(yLJ`J~?Vhb$5VM zX+6XocG!Y6;ub_0Qn2nS7sNrVUoVQBQ)L0{b85<0F0l z1DK{^sXn>Nn;KSvTEh}JHZ0O~fMi$Y5(#0Dmmrr>Ng9)2)UqprU;8W#f3ZWY%>J{c zRpp!MEZ(b>gdE)JHKCX*C#5De3y$O#z=&mr54k zoXl@Vb9~_81+C443{GXJvYFWYOsAYd3n`nXNwB}bdv~uLW`*2=_ z>&Xh{hyrwZDDp=4CeWhV{wT?xtEbdP4e0_HX;z93AkME7Nk|+-aWW8jLG?8>_I7Pq zCKX^=c1C3vKx^5K1YA^7rWjFqT5)B#R)NogVln>Q?fTQT&Ps(%t7iBT(GzwSN`CLq z8Sx)3j+TGV8LXT@hgOpwIXGs_^Ca}T0I;ia1$_cADk};ZN(E5~4Q7w*2oKlbg(&-@ z@3R{-chLTCBFNOel16zteGW?);^X*74P~RoDYON;rc4G7<>VZ;$0>SlC$` zewm;Dy2~cwHMW1*!hQT1-@yVIf_7Iz?(@|#!k~YMQB(%3Cvr(>jl6G?v;t_6f}{y{ z)r77UmBhA1410Ui|30ye5{Fx_QG_|9&9?A7KWY5J2;}rkXA}K)#%bHxwo9LqQ%#(l zi_h5-*CiwfGYP70DV|u-0n_v-AZ0MJq6x0H+e(w;mzzRx>@EYl5c3*$uH#0<2V9*F z1fzf5W>gt-oo$1Us5HNvD3rS#k|-kS18gI%Nw}0SAj5RH9)x${iV#ng4-DLNb3zh0 za`Hm;)7cnwNkhwx%LS->-XhX1EP&^nXSrA%fjDAg8Qw8t6F!we(j|tUI((^aCbHpb z0P7)}kqV$JWUJ86!L2^BQJ@Rd#N`-FNiu(pC)uyO{~}s$;10dkP^}`KQQ&yUBy^QQ zOKQYuh*>UI+J6EZryZ9GJj?TuI%h=0r6ke&U|0#W%)G$`i@3lxeR~vo)*5q)lgz_R zQW#nIwCTf_V2P^Wu?K8P`|r3?Qyx{-3B$}VuBKlXH^UUyYIk6e3cW&4jJ2*HtuTKy zX<>4}bo|Z&#u1;<*`>~a0}JmLmkDeWfp|JtXb|eTX_>?PE{&2jN8W5yxwS` zrotdV3fIEM&9vNK2y&DZr=WlRCVf#)zhqF8ChHm~Ve*F@Xi1KasF5*WT%CqiB))LW zzR?u9`Y57RwFh|#_PI9{=q4Uf&{Uk4cTtmTv112p@Ybyv0w_H^r zq}z}YetVS(gS}Vp+l}jk6-rLs@S*%>GZgp;nZPhLO((Er+O9kuB2MW zEZ{wGbDq)R61FSjOn}^1#%7DlN*D>6c_|CRJ$E`V$bsf^ExP*if!|8iWWcl+HrX2&4$xMzhx@c4Z>IMz3b+07g2Nq30`3*pM>aS8xxvcVQtE%){jb0b`jvBT zu&NePiqP$)&C;veY#iRfLWGkd_|Z59D@3QmWH+mb3Rc*N_{7oIW)7UnFS>n);rJ50 z#^3pn5ysG_XoNX13Z#ToF0Jpp*&t$_9#ByyyK$3`UZXy*c`S8H$A0vNQHfB!$`8r1 zo^U|>+;JDoq6e4O^9z5|l>yUn%?AJBpO@|2f2U=)v0+n>Gwm?JQ!f5_G=!N^SVUhm zDfP%`De4-6@->Df*6V3Kic{9))@7(T zrhG)6*ox6vx*3X>XA;dTPvJ_Vsk`Dhb!6z`z0*5VcU!`kEYqR^MP#Rgk;OHu#F=N+NL6d_Vm;XO_ z6j)kfs>EpsfN}#eKBSqDQ-w5W5h(ooEFE<0sLwv>aV(1h)aDaD2DhsiupZSNw3z1& z*p|BY`0+Vw!)bs2NN-P=8rReKBr)&gcw@m530Sd8Mn2*XM<5Mh;L0Q$Pu@GQq?RZS z&1H{ljh};|2E})5YHR4I^*&e0;@@m{_2$I~Li<4*!-o#0!Rc;yH$VzoHt# zx$_bW*i#YHdcMod#axH^#2%{5vel^VFgNdWS1ThimxF&}{5kLXtff>0=*17?UNmh~ znc~3$=mV3>W^3+YO4y0~VHK9W)ZXQTvtr2`9kR5u=a?L)=`;5Kw7Z8aYhozNsRq@m zhqI2+mi=IsJo@knjiZlz-Xuw)*8f9GJL!J47Cpvc>$lx;>kV<@m#c5<`{X-y7zWb5 zgK3UCjnjYZxHqT6wO*~;Ksh);gPY?T6gTH!$<4u2dO?kNff$PyvN$r)M{u?bz3l-} z2E0D)ql|jy)Vdfbl6n{^5Y)j(cVd@W-wtZhfwehY+NAK?LvrI}#jJB+t*~u4Wi|Gp z1xR6YZqBgk>b@*)+Di2 zm9u~E&}B8&RpP6ttDH{v+pnN!KOlKt8-}%Z44dKRWYiq;pWjmMhF%~fIviVKkSGWl ze~d*>TQ*j2Fs>gtj{k@8yw^#?U!8`ys7f;;WD($ngPWC(FxUK%8@PopexDrxKP%@7 zK>|02Aj1t1p|x(i@REXqCoE2Hb+#44n-+g)7yJrSF>9bAKq2k5w%cZH_L#LGTqNfe z$h+id@xYy(0r$-~K2p4}>#o&v0rl-G2ZBAbPXiyvhP*qPo(>KHYIYxY!g zIiM3<+P=S}eXg|u&z3&e44>gnB+JWv|Wym>?E)r41r5{Tg{ zOBVr%-AZ*jg&J5(l;{-S(s-(aI6)=95$A*v^oF$M@bgF8|;t*9?oFzkCoefb|OeGuc&|7dLn(rtB^UA*oCrAs);7 z>X&lk)?!EymMT>QtuLp)y?7yXh~KG>o}~cD{30krk!gGDFkn)i;Uqc3tfr)oGJ3!TWve=yzJ$@s zU`&e-cPP#x8DbvTvPAh^pd_x;h!Su1j}AlmCIflehLhZ*-Lw20h~z!Jkwujlc!3= zmL`wiAL&hMJ;^M0V&?K)7QbHyc1Kx7ZWbD zzg>npnQwoab|^Y|8w_k8b0af|&|mAsrwIA?)mRk49ru%CZfX2Cf~mJfe~o-FL#Hs0 z7k@XuR*CR)z94OH2uroCLg{FnK%IS>h8zrh1h@NBG>Uo=f`C;%MV~zfKBVVQ!m6Iz ziva^TXU{(W?!T-`0rN`&j_~r|byq1&vs}>x%KLx9VKyW>&yiHrasi&LX^4YV%3!8P zJmLj+eXM@o4#T*#zEN^o!lLx_U_%C1_Nf3PKP z`4ZJ5(>-bvjY=csZ=+ZlIa=YTMlMoq?$K5~s{%k@vh50FC3*iPxb8h&1e=VB8(Ea0M_>Zz^AB+0V{)({r+@3(A z1MKnAjZ7HUPnyyH&e(U>K7oJ2NTI&}Pyx!1On?eX{>A6!ela&e#$O=T=ru6u-=cqC zj39$9ljHCYu&rk0V`1n*0XjI7N8M8UJ&mltXL3qNoAK0|vg&4V|z~tl%2NmRB0ZhV~3K6Lq6zCu0|4|~f z&c>ez5oZ*TyR{_j%jMubRaj1ODV2o9&ai?H#vE3<9<9^m7rxxCdDz@_J+2D?mMmbQ zfdMP8N$7d)8+6b(>5F^GJ+U|QNz(?6=?Hcedog3;HfE&SBC_n;69wf5>@t5;?mlq< zviIYUE_pM7=2#kGEB(|^@?;B+-#z6__MU=|tkU;;N)cbY@i(L3Mcd)==M8p`iFcf0 z1YO4KB+X-^HCz3HjiSB^I~|^~hBxx;HC2@hVw$&FsnUJ6*&)9wl|}z5mA)?>(T|EE z(pRW(QgLus49rR8(jCBXjmm!pOIwXL1S%~WYK*bY_WR0<{<4Xx+#i*N43J7AWgdNz zs02^%9byO-pRruqqjPfGKcz(uV0a=bUIq&o+EKYCC71t_RIqk`4pW_0x9kvlz74?A z2W?}Q6`V2N_6S^?s+CT~(U%_0pT!c@nj$0#91eb{G%CbSZIEu}BX)n)>X5>P^>{wM zEo|p*7~84l4(q?`R6n8x0BAbJQ`2e6cBU_%bGjYDE zohP#EG#-xHKX;wuBtD_-fO_eCIEIR@!B!UCgZ~2B?YGw{9{QJcP63>l01`$M5s-b5 z{G1?*9d2UJBp@;K2?3WYCILKuv8a>J88!pMFbE`IP$)3E#3E^$Cvxrw6TrX$VX!a| z90US^L_iP>1cLx3fj}S#gb)Y<6a)eRgg{6P$SCtVS(-&aMp(3Sh6atos$6ac$~mc% z5nSc0FCJ?Hbl1~?jAP>~rPC#m5lI-~25cWJ$91x~X)tze{LDQKb)3O}Dp~X@X`lcZ zg#lnNL>QM(z-_U zEziM!_}WwIjn3LPTKzg~<6)RB!I#qMAd)0T?A0$`K@Av6W7GCpO$*HUQj#o!T#Wf9!!>rz;hk>X6Hp* zfY7Gn+@TwF08^g9&b2YU^Uo*O?ZspDe21JQ#{a@>k`J29oXEj{b#cL1V-6Sfndqxq zZc|(pY%@`~UF3ExVv^hsk{swX+aCF;ojH=B;7Z~BI3hYm3XJVds|v9%*pd9jwz9@S zI!Sc{vw!2zZjF`$?+%f-oz({MkL^+z3f-p*ird6RadnBDQWMolu;xqwM+mqG4JJ3b z2fJ~By%FU68<|^w?6Q%}&KF+ij6d_NFQ5(9y{+Nos5t|i_7=7=&bPuyUL0-<3(EOse355d8 zh@_84BmozLN`~NrcuDjpL80T`+9vjCb(Az>Rdw1bK$iS}PeiG?lQxOJzCR?GmXmUD zj~_%~9>Q?12;PAeGhGMDY=*_nQTBj(2VT=FRDB$kD@(T_G0LY_tB~&zvP4M+jYE)} zo{^!FM{rRYollWR{Rdk3C5hX00cKpyA^vR)SnpSVIFkK(CpBTQJX^>aJ$weZP>Y`^ z9gAlM!;Ptb&jkta@@*{55L~Gw%!{h~{x0%K3E5p@0%EA``64B;OhuTuF{F41FTfbVYtBQf!UkK?V=lzjuot zLQ*cSzZRsxCrSiI@(V5MAP#W9hfck=2>_*XC5jw>LsXAoe$FI<7%DiW?1CbqAF>`# zE=VzzvcWk>c`&z0Ugz+@CE0zsnRMl(XlkxmEEoK-NL^k{e8tGOTKUA1>AF@ivYagt zKNWbwz|T{(oBT{U3D?9l5s=9wSAST;op$LN<>u1$uOxR4u06@XhAmFXe0hO+Rjf8- z>5c_|3TBPQ?Iw*MT!$nORjLQpyuAl3fBNDB7N;6MGr%O{sejE*)DDaB#ZW|} z@nNv{a#2f|O;eLz2{50VI0cX1rWOgHLkGsnsA|0nzL7_nx9wo2AS03@WkBd|J3gVT zM=9OU@g?s$P>;Ji|?7J);ruwhDg=oti@Mwzuu9;YtEV4Vzq<$@S8 zd1XCP;vgj=J0r;$q56~jWwcf>0hBbI9Utf4sVmYKga9<4`1qCWIS}tCa4Sw8NOKP!Nbo#x1aW!CHOy~zKu(8% zYH1-htB~9ixVdMR?)rL){O0!%n@-aW+c{Y10I@Lu%*lMJFPD^}lm{)T0@`czUtIKc zj>r#1l${dBLlI325Dy6uolzJ^6FU;Tj%$xp#EfB5(De4qxs@(r66%i+*75d#(1ha7 z9Zf2@>1n)Kpf14=;%Y;zrQg551YI|3U#dN)W9%69 z;+4PwYw!e@4pKg1ebj;KN1bth8pw@6oD&?Rfk^NBO@o#It3>S?!g+CrLpl$Zif<+O zD;OXjuv!dxVV8f4bDpg%W2(L0w`Xi|60GeJqnxdCZf5aivui-jNC1E;)1uHAGL~iw z@-Cp_Pf%!Lgzr_{Te*Jg=OP^k7%M!Tbpb>?y62mOiUC5tqzNh$p~qf|iT` z)yW|)eDYtQWiBriGiAAxlt|-fndIvdB}oTsM9xlcSQO=k>0)bt*XFxhFwfaVb8MHH2_XM~-x7 zvW$d|O#)nmN-eHp& zLGZf~^4_J&Qyvy7%$8n9_~DdiBJV>2^OOlJpMuTu%hu#!Hepbeefcg!4Q8YNK?!IJD}RV-Z(yP9IYf2x zdf0n#W~=oWI$x5v-En>rZ6_(7@Dyzl-q@#`Ob$|iK282&kFExT!=%AJcK8GZH1fFQ z#|?lt&BcZx;Ov_tM>lN}Es*lf%1UbomTwOv27R-6&gZz*-dxPNV9GbBHg)2{KDx|o z5=xiLT)VaSG4AA%?ILz7W;0%#^vD{No?$I(aGSJ;Re;nv9GGBG-BSN%fPDTueLq}| zzxiQ*&CCxRo55j8eh8mlfBeM8YUPv3YGFsY$^^m0GY$V! z!Kl5w%L%f{{8rs3j8)@!_8gS;8GaQf#kpHhxCPDHaIB}7T>XtuP%#`iM-%bG@1!1* zQXRiT@`gKSCf#@PC^JEELQYB7&Lu}BPbB`r@#`!wI|3g?@KM^G$mBtEHf zpL9gne|?{ixz&GU4(Nw_>@jhV!A&r>8S`#&uY$?srf?JOK!aJax4u0N(JXTwQ{H9H zA3#insb@y^cyyT)4D#)j%29auK!)nsj?gxz)DC(sw9zw#1YVXA%LNDWWNTs|^ z7dfw!?5Xk5>*z{!4Y~nu=}BqA#i=6bGYZxKE9B@)!PPUnc^nLEQ_T?x+ zUyhdX%Mso|HfX;b_o!9?id-;FQ?6{2*)%RBmJB%`vmxhOI^;ZAsy=$iF`mXR7Emw9 z!3CnJ!G{g}6;WeAZht68)VtszydVC&J5#Rq3fM%Q12*cAz((8!+FpT;D{{L1gt_Jv ze`Kbd>3Jy=y_hnhGy@PQ z>%|wj$MCR$ypa2O*iYK!Qi>IJi02~-a!eXn_jS0iNNVay!D4$kuEn*9hOa39VjCMT z-^+xRhVRW}V@xhRWwT+kQh-nz*ZpgLfB1jRI{{tH$s(Le;j`LlYEg_QNG1jr3_3(U2BlWa=!z zI?(Ib9E(Ec6K{j6q=9j4PK-u4>ddJ|9KV?R#J*e*+5FPZe1!BWK*w#)>9Ja%knkUJY8F<5J3-3Na2v zr^X=(q}CEoa9q`ksfRXGO3ytF?M6!DT`!$oQ9^k> zJ0ZujIXUaJ|H4)e^Z^YCKOn+`I(H`^bUg(}rT2I{SghenL&AiW2j!Y3e*&FYMWEkz z5$I`I`yV5aQ+~3u7YbfYI9yW9+UsG3@P@{x|0Mk0rx*0sk&XZo#}YtucminrZ$>ka zgp`cVi>%vpz3}hQ0XY}yfKL8c$W0NDe@#JNR0-Dx0>AwQx|mB*x>Zm&yf#B#2y&T6 z4b$&;f{Joa*eR$8?Pf$A9)jG5)v{y*aq7DRn_Ol0)+UG9|4^-Jh| zjAWiD^^OuULD&-zYsQDCiQq4#-a@;VzlzHk3rGrKGNTV6qetjHaYi8K;;)=Cp*ST= zXx01mvLF^^3%wshV|LerVvHKU^Sj*O%2qP&)WHEUq~JY8e}|Sjq+k_4l^SFij;(~o z^9}W?Sm~QCSSYd@w1-_0-kl!i(F}cu>_8W4?bhPqM?!J%3eg5t0zK4H zif}h*az_c%cz0+^nxaGEGOaThDjA#KmE_w^2~8YpbS?%9w@3`pEaHDQi%OqQM4+k+ zVMs;zZ;Pa;SS^K~ARE+9%inhbB1p%g;v6X9(|7T+e|=}eZW4u0<5PSCbYYODQhd*& z4MGf$S>7N73(LM2jePx6b$FceEDnvx2bJ-DXtbNai}_0)+Cy9p~DZ^CLb zH6S?@vUmogh)NDomvybYg1Ydn8phg70%UwiGN~^~!831*@|sQL?Y4+BTt^?G>*$Q{ zO5uA4e`r*_U+5LiTZIWuguv^D_7stL&>~XX#)wprrJsv?@(?iUHrVL{jBMBF;1^&H zoe5)%UuLGFs2X*xt%L*R3XZIq!I6c1j6=m$K{z6I5{~*YHEwXi(FU(@#Gx0CxOs*n zE)t}k{W)pC!w!+ue8rot%B8l1z=iB>ZZe!Ye-xoej-SY(sG$@sIg*NG?N0hV{WeKI zyG@d}8*TIoc zKrH%ib&z8|6`0<-wY72)O3k7+~`aRdDOPq`CZyS1IfRrA!x?S-#m5gJ5AoF|IwBmsnE|`BsY% z407%lUrIALJm{ddf1!PUwDG*Q+%Z!v-rJgz@B?{FdW5D5_YU=FzN&byhTr60L_;KA z2lrAoK42x3!_rnPlzcDbx876BMkF5mmT5oH$W81* zM-CknU}3&*>ZN0O_r=* zEMhhS1rlj6L=PqxSROESs=z0>9T?hR_;q74{IXUw{Ca|->oxqA^QO)s++FQVmX6-f ziG)r}W-|7cL~I^^mn_x@^Y(qQMtU#R7a?XEpdj{S(zR#g#ImKJwe9SaU()RnMr~wb zN=H5m@|KfT5BQ+ucjMvB=qbsjU(xUx#58CON!@Npv~G7YJlD5bY^9Lt0Sz;0PP+y--qL!c(QV(S=y_l0?qh@iu)+EXEl#J`i$Mkk4TQA)#@%*I@$0*8EkAq~GryK*HF zh>J7=a!Db=;_i6 zfcwXP$1MNmxxMWAqQ4q&hX8Zf0i(=Vco4m3C{3VCO|AM;v@JFQUZ(@#FUYhbYd9~g zPkg$B^ZmsstwYx)DPKgbrSmMQRSV{APn}uJqc_VS^nmYARIYO8^*2LRadFytKRLrd zjd{8w{G?h7jTsf5?VZNwKegW*VDxKryjWhwOQwkS< zFuE?$AD$DPWKDFLV8)4(@vjw+A~_PsQY5&l$RpkV&P63XE-)&zb5V8MP3!l!0oV9Q zXa1D5d1N3LZ1p(JdR6bbWZZb)KR@w09wraiQC{*9R=JlHW)2atFd5GpuuCtW( zAx=czK9>lYF^0I6ur=m4g|&#~AA3NiIDxtiPo!yL8byIR=CNXlG1+Khlq*LZ12I*vxA{8B0Qc$}}G< zWhM{$1g*LzH0O%mtFgTLWRy3{9*%C^)DBfbAktv4&`;HptEA5@Z?!-OMSpSMpTKiN zMFEq-)P(dU44zn)PTq{;nR#-8e$HMo)~fiB#vI)YR#Q*L-1|=S0@F+iTS(5Nh^M7r zvY>HD@nZylD8gmE83Q9(ui*86(tdALhj|>186A8@Oi&`X_!L9;i!R|Yl?Heu%Hs^_ zh|Z1^Cg7p#r>_ez1}56jYZRNT-&Mahfv#ceVwYSTnNS%;-zy|wdB*)=MmnKU&rQ3{ zd?{oW?Eq_VrJX=F6l~1ZAoGsOL@G{(vO0;818&rUn0^ex;vzO5Hev^VW9|)G6!7eW z)Hx=7Sg<1d6IxziN!nPxsBn<*O?K+-q`7syIGkGrCtPI}$sj^@a=_B9_f7{E=QtrC zGGRuBVmx#Ey%$^E89l3WSQ26^|LQ4X2>Zza>0c&?9e(Xxu24Yxw`w!EA{E=NMrbT+ zi6u&-LdWm}5Ncu$fswO+&->v8^PCGTHNo5ykoQ4ty`W(jyvyQBcK$BgyID#FI8p^; zu8{x(``Mv+fGaV)@tBzeVm+)xlr=kYHa@Da()94q!5NCBaQ9dWR^mFpZDH8y(=-GS z71BbIvq5YT^$U39^$YS#VnBC(QJ#13w_``Bf4jfY4{ydR*Zk{$=#}6SUy@H90v z=ZVAZlxm#75qBWZ15p?h+~^UjI49VcrV<&(3UezEgM4HdyYH9-wjB3Sw{kG8jR1T z_^cyW(RJS4NmZzSb%4>9sgCIDOth|?U8@M2&Or^y^Gq|w37X2D^4Tgxt$aZT0v`P* zsB@SKT?0Da_G=a~W-yoQ6|61C-FFe9L-zXVd1*%zSv#8hEdexDWQ-M>G9 zb*49+%GR#pa!|UDcdAVuXFH8s9!a*rgWJUgV*5$N=yh9vBHDY_vp)EJQ@h-i$5yPZN)wLxwv@Yw_%bF5BPMFvw}Jccl? z3<9$D7xDst9FN&!J|;lB|3jY6=cBLaI>~8mUvO$YjX9heb1_#QkA8zJcNh@!?2vYS z*%%MignI*j%=-eiH8r-GzE?-@f5J8{=~DM=*!U0ZB%wuY&srWJwkHaJAMqQC?O9|P zOlyI$@j*#R&ETbh&0u42Gnm)F8SL1%vz$`VQnibg~qhl`OZ8CYzHx6Wqhv3k9A zaH8uxda|1Uhb_#kb)10>Spoaqk|XAJeDe0)A@b}%!gr?$7gN>Uh5D=_Mi_<$gR(tl zD*nYHW5mXgdt+vjVfedPV%Cv~XrNamv+r_ooAxdm%VVccto8m_%}o>)?WZXBQ@=R^ z7X_ey({D$vtijoc^=+03)c-ZUkH-VX)kJl58a^MJIBKP89-ABp`cuc|@w7m2z=cs! zGfI25r6!r+W}ED~c*0)1gfTSPI-aRu13UQ~@5wA)%DG zn(J^JB+SIDmEC|MVyIqISMZiD&25!~kas8xvX{t`VG{%AwryFUFyq5@qJ7iuWVaMPIAp{yMHTi+c#k}T;?vzO|B zsl~uhN{J7G*DHL^9j-`Ae-OhsAqe0`7*eM{$s|LVhFQy)hQ?8*VF+`kVIx4cXc{(> zM9V^I8jhDp))PI&vu>Iw7;ZuqS@3tTokz(=-PNOhBBN|PT;+KZKh?ggf>=l8Jy#u4(c5;_92;iU3NJXfvf9d3@>fPw`+E_ZO0Ts%^qvij ze_clhx{vX}j#|d^gy9u7NUeLx#reBgRBsKsP^%1^2YLccRu!hT{r_ReRl|{grPT>s zuEOh?1|ZcuLes9%vO1C<7v+WElz>_#A$CHJCd;X%QijYI|y)ubF ztG?IKw-}TABOXfS>T}7vzbZQ5L0jnlqGm{J*^H$u91gSi7oyz4Qq8}Acvq+j`UF zsWWwF;uyz1)G>UtuQlX<8Dc-8!J2RG8ky(vd~?F^mUX$oheX1LTnGA6Tn1~hFYZ&f z=bvszf9!A&FBXMLA%ML|>65cu4?*LgeTleeSR36J8jp^U)BB4%ep6)(c$|mTRD&F) zI=?p+p5nZmzyqlsPA0-4!-NFa7T}Calt3ab15?++XcV;QEfeQ|XM~dbJ*%D}G$xw{ zh!}|IHnUXKrp|%z74}T2R-9^Q!-U7F4t<&p02_qSXVlRW^hYn>Cr=3K!Ts7Z{s5Pd zR6o@wCs^)2C=mXDQ{VuxHJO1(yDQ?Reh;q_zmsbp!CXKqM8mpwC1@d5Fm+xY>@izD z>KF|J43`8P`_D6fx4*}`db`$Oeg(4Eck7LUR#~o{86dxvvPSPtf!oh_u%!a7>_0MR zaYuXg#tL7%!q`8);MOo?hHqS76cq}d!24YPs^0At6>HQy zF26a$9}2**HIvQ^Tf5FL&tuKFoRMhi6&WM*jAXuZdL!3=9KFY{fA|Gql+jwl``}zZ zUcvXrp|X82om@Gy^18nhZ&A6Icba8Qkg#Hq7Kg>NN^VqJ*u@jP@V7BiV8Xyg|AtUN zT!{p^ek|?NTIvID-B;jwkSZD_ycfN3QBl2b;*v#hZ|ZL&oT;m%FW2q2Q&@XfJlz`h zJIr@S*0vmfi)DSJ4}mi;KR6oQ$K4}KZEAi__%lN@iy_bjtAN;ffq}Un_5vy61>u2? z7uIU2zrpzyzfwf;#8?BY)b@3v4N;634b-^qc-xG<-8Lgatw545Y6E~{s!=h;JI~74 z)!DN|zCa0Ub<%2w_~iAP8S92#U8`?GzNs zx*syhFlWb;SoYUD0#M!E0xoyx-QnNjVnRuOFv4l{a4T!$^yoZ)jSs^ntLt;DAkLA3 z)#z-CvRG0r`hx+$f8Xz!5&qscB0bt->m9i~G~d~<6nWG*P=fS;<$I{l<6)XuI^mym}%?4{yO5Ga9L^BMudd>k1`* z;zdaIE8o`@&mmpz45MmCSun7e2r zj9XPHfHoYy)I&KxDG&2HS4T<2dqttTy=S51GQj9*c6SC?6Uoshd@=jpIc8`w`755i zx>#+*CPI6c@BPpsP{(NTn1R~K=;CaDhjl^>8(y^d&G(*sAinp4Y6EXx$=YOh-33?! z`E$N(eilXLa{m!Wr+!fwl%o$~ZDtzsp(8y@#to8#0%%NX_o%a24KCM$3=17O0Q)=> zA1-odWjor)bzlcga|(aWYPxYsI`^mJWI$TGo%IDY4}_0dkjp3KkK9LN8Vx;vgOO=@ zV(MOSJj*>|9-V#MR-U#>j|b5Pgg9jh?wa~3+z;y)zSt1KWQSjBQ0e(Vr|B&%{OLR- zyZ~~>6c&I@2Vm;<2JxqaJ$8Gl1^uLRX&ZZI8QmM<;)s^vSD1GXwV2m`3ETb7 zKvc6N$4Zts_?AaI;&3BG2e_+5zLU0tosQ(rF)51l(oIxnWPwnM&u{_WQI$iOQB(pz&wpEeITy?sf~KRpCQOxaNIy z;=P1!m2Q0ezh51c1qxB36Pc4$#6y<`sT3=VQ9<|R$O<}sp{MWLKWDUSm}h!Ue-aR! z=rC|87eeQmKi*COc8>1$?vD%o{r{$gb5^6G*_f^l_Qdj?L(61gf12fykTqxyHDrIY z%GN7qGj@DFA0%_M7f?<|`T4XqRa*Z@<4Apa37n`=6aWPyzJ2nnEAzCBrpZ zkpDaY?4ZV>E^FCu-bklIiXkzhA+Py#pW6CsLz3}M$G4Rsf4rkAr@0O-ofId9 zP~Sd|IDH^Yn7JNb|GS1UmMK)?^vYcAN0kxjfH$P6;(tW$amG-h6t^r?4w*Ut*TX1> z!IkXJ$}VgVe~7bpA^V*u5}qsvklq->FP6`UOgl*95(Iaa>xCRRH{va;8Rbq*AMpBZ9kW1>koKtG8?f3Gh>ok^uhd3~`0`dTQnQivGY z5a40%%Tuza8`wmD-bzZ6J#@Zp>m=EO7TO1=H2*f_r9$45xw+?fZNGJ}ByE`n~V{euBhRU8R?(qf(SM- zg%3kEyb9r*>SWr$YDh1RwIGT^ix&!b$}>LSa4CJEsyc7Z`;uBqBJ@t56hv38G`V!Q5!^h2?hr@B6k)!n6(Z^Q2VQfcoR_8a7yFR~; ze}TDTmKY7G2=wYuruDXotK3p+y2=ps=ay3KHttDUxf<6=$ofYPz)d7AvBa}#(n}>? zPcxIZEXU#H1K@tDYem9WuHVExj{~Ki;{agM&iJXRw$(8AiNKh1K8iKL2?^9p?yEd% z?dZxLT}=hFz+}#f3w2! zZjZ3`qoSL4A}C~49is`4N&4Qs08<5a?wpr9cc2p!zVFn^n47D1;+Y?C=gZ{GJARk% zZ_Q<*JiEgo(C+A;NJmJY62JzYwdew`$W^=UUKYZL_krRaW>)xNOLmm_XY8zeg3`}) z=93bKa6%D8vpaKO&7NA_iQ^@%f495DAFYIFWSj{xlYOafz3z@o0qz+4Vgq2KSq$hj zsNhsxS>=uiTDcR@D|e?L8oT~br=T(kh^i~b&(W^UnCm*8UJ|_0$~P`Hue&zqgFsx; z5r9ak$-4W#^RC0X{8>GvdwA;bDo-6Rm%nq6x{}Pf=bSDT8;I9*&6wFxe~a04=jpTB z$aDgkO6|ws&E!BwmLpPbV@r0YWK>raxV#1{-Jz?;5|t(=Icpcl`j?^6VMpc0-X-m5 za<6+0?YIKt3JeU#&_eETQ>JB2BZX8xw%A`TrbP82h->;?^~Q_-bi}m@>#0SGo#zWf zfnCiI{GAA8>V7XrV-Zlze@L9ZTNwgz-h$L+tGj6MDNVrnbH30-IEhxQl;CDmQzwf~ zOi}1r-fLcLfrCc4SybygmT>|K1)S`wp<3LeDk2x-0oq)3+91Nz%6&{8POYvXF#0Oi zTji4-YAR$_{vQn_(_Zqmwv4S5Dk}-4EXSge;I2n6a2}6CQ&X z0JctP?eTe{MEFcQrsnFn*ApSNQZtF@dqYd@^WNw1yv|K+r(kjlB2n=*?iHgrCsd zwoTT|oA$0q;%KE~_ba6f(a)^{@nSbu7VCvp!`BR>^6Se}x|f_at}^ zq+I0osItDv>eM%qbXrz|W2m1E}+xSRK$>#%o z8*)HNnYz)6wxkl7G^6Q2`fX__+KK#Ywfxdcm zvoho@%Ls>_hz#JdPH4kYtXFEW3(sah>{i_ToSvZXt!^!WZTc{N{IOw~@OO+oOsO|> ziAAD3*S=sy0@h8`%?-&3z`ZT_{qLN*_{w-teGfLC-k{S(fHgbrg@F|BD8r5iP&b8* zD$2Xj0LhAn5fqYKPm*vfhKvbae}BY1(tSXR!E$X%NmLq@P9(1Aq8hXbHM#zlncS>TW{r|%b@M}cNnGC*$;A=TG zt6?xVkH1skWKQ|&bW1+`0~(iBQaYsk5YMQd)W@0NK0$-uR~AK(Z5GG*e-b_K0Q)NG z3A((_kN$}iqV>`44ijs1(%+G$Z-A?%js3uSN#U)}D-+kJ4|9F8psr6=5q&!bw9t|P zeFQV0XE+0ji)cV4F%2jP_V^5@2K2?Q^pfu>j(?yN`IjNCTO>-Hs!V{|y4WCklV$_0 zA+9zyz!oQaDv9A|&r~LYf5RX;7i3`iAm~>L-a-$6K^{JL@qe#m9O{DKY?_7|ZSzfU z4qBm7AA}Cm-7K5|F#4zVqwYADV>DS@zi|na{CZ>d+(Lke4QP}|+PZUUKr2a037$@5 ziY2ZF^c}O+ad~)&>KjaDZ0xrggbVyP68&3m1Q2)+jD(ni1;9#ye_SvXfQVR}8`fku zl4hZX5W_H&E(fLN`!d@x$sco@}62nfIV{ zm1>~ca;88dC~SeVfKL$yr}YfXrDu$=Ot*O|*~^24XN3E#rOGm<-Ty+kVlv7S9@4v< z2OY-tXF6ib&h>~Ef0fqs@u~K)wyp$F(AQ+e>2n51B9_I=HQCt{e%-RR{Cmf5(?20YFPuxX)f_y#(fG zw^Mll)ud8Fgwy=-r47FChZp!RPJwpDf{ig-7l4&n;tN3+>;fe%h(!{z5bQJ^95MW$ zE|A8}U#tRkai#2}eJ+$QUN7cMhR@z{2z1as>58#3CVMakWeIWLs-IGZketmRwhnR7 z!cO`&J&Xb!f8U@hq8r|zbV-43(5sAGW?N9>n~<6UNuCu)Y3D*vGr{&_Wr@ zu~>@uKxKEq52_g`Iz$Y3I|>}MlKrPK-&=hgy%^I40tTyo!w~v=+rz}N^e-RS9~7mQ z>YSTF>8eyygO?vPHm;yw|9->6^?6+U$pCn`nS`6&dTm_Ba?O8xx-S@4>K+Ir&x zz3FxyyLOFWjiOZRa+8J#nah2ONogEF1yu+}yliChc_AQiDw zcGHU1oWtDLYa9q?+0>hF%YWCOT4MYu{l|aDv4T1mAX)9NZXQ14J&Z%7Z&-a2-$4&c z!+i1B%|on!0tNoB$WHZY_WtU@?_7jUan{C|f47neOBmvNR+Wb7{V~!m8g;z)FE#~i zA4(Z!Thig(CwOAG*4W#?xF+(bTyx|H zmOX@&cI^Wy1h-Q`3`M4$uouc1Q@_6+jM-Xv78|IuUfYHti$ujB6#;~E&_6+#SJgqn zf0cCxL(kZuX4iGl@s+)oYbt$b4n_~RKW?Es`GQD|) z6MULv`vVHep!1>Pr_jB@h6ZQ=!NYume+s?hsARxw=jy~!v&UF_x~yUbT`TiV`oU+t zMvs8PjS3M5XW+{DV%B(!rnu~=hAU`WiZrN&7AOqz6J-_MZ5H=npQ3v3v#VZKw1kRc zMG{;h&vK2=slV#yO27@TZg`*92B9}~)DGtCwGJ#?U>#ud>7%yTWm#l30%Qh>f9|My z!2$Y%mx~sAnV&Sw2Mp89KzJ8}p)Es2|G4J4484i_J#!7vl_l6HA`~anEXT%ohcfkx z@5<(hu;@;7vfrhhKaiBgW#%r0jzs|&a6qily1;`>NPH~$_^e7EY*dd+>M}$=`v`a* z(5MigVgQZGCkq+3XrIU`Cn}=We{TROg#G^RsdV)_bq@%1%jsTy0D3SrNfk$cIaHVD z3K-$%S8#}E?|oHxp~?25I$KXx+p>_CI=8oAxGL+cx_DI>>rrD|tm-bN1sVY~DpdeYTg@J@(6+SGh!}gb@ z8u&@BF`fxHx^>yPK8G#9X8?Inm{F!g1vbQ8P0i8k=Or@D9i4j@N#_m_SLcAZ#8kM{ z-uPU%BA@Fnk?f>=uA}ug@MGn6rmk&E=+NKBddArI0t3+;PV6Pbe{TX(?cZRKbG2Mi zDn(#FT5dxvqZt^UH_MGl62EIaC(EVL)%~$t&f~m`0`@3aJj=% zesN{#{rRZD+zmQTl&JoI3uY~sWDbKhgK3pwsATjvfPg-SsSlo_hWL{T&mWuMkhb2r zN1N|lv)LGKe&^nJf2CzZQzW*yCKBP#AQEzW?(GOEocZ%uTA$q;PAIqU+C7YHENW=22#{)He}N9J@TPV^#@pN1 ztx24|gSdhK!RIzwUu*k4gTU)3mKEM1?~M#%{yM4PGu18xFr`{tUkmF-VVm z1A)N6iO~5kfWBbTT}e$?wOv_LpQusCN0^mmbQA#$x%ls)^)W2FqPt1H9lQkl`wPS7 zqVN4P^-kYdWiBaRm!ZR1OhQ3H6*v1OLv~nEO4DAy{fk#qRGJ8i_!+Zpd-1#*8aACV zvb|>Lf0*FwtktvnN|kZ?hx2imyDR7((#n)7!eto4z_ss!w(oU126Y)&tYb-RqIMC@ z=wj@M03t;Z5s-bXjx6~F^GjhoYEC-tW-+u5_&8da^EtADXYhnnN70Hw2Q`%zMe9H7 zI!TOl{(X`Ezx*)#Tgdt~d2Uzntr?G@0)^+O1e4Dh=9eF30U09-0)YS`7zhM{fglw? zAP@u)Fa!btK_C!N1ceGg5o@NT1K5{aWdR$1s2aailR&IdnQqqWRf+J3D7v` zI~_90(;${WUw{KMtgzU?$ge34&rEN1m>W8=HlkQP8ctyR(j^{c5D*xNyeWD*q`1HcHIYg9P@76Tx_WSPvit!R`87eCtrxYgfcui|5yDxe?1M17=J_G zkF)n}Y&Z*GM~m&k#V$2J4?ox)=QnJBp)fm6-`8>IV1+w2y&Pvb@-MM-;4hBT_EcmJ zNbGl3@Mq&JSmjef#%xB-NcMqszobcqyM(2ZolzbZCkI-JqjW?}>sg9*o~42sZDw65 zEL}G!yj=1&Fd{R^h(2+B%l`T^_S<$$CPeuNpMligr1jfDF1992NP5~`pFEs@-gNR# zoOb|Vun0ub$#87m)Wz%_@D0*7Sd+(=kRVUE(A5YDwMdl!s(G)w$pBgxkiH6_16l*p za@y#>3-b%E9b|V^a|05H?&LHk5+23R1WVYzc*LTqWU$#0@r)u~H z3;js9Mvj#tYdyPzewNS$iUh8Ir9kn!3iae+aW;$1UfrK*N$CO70_c1KLF#SxI#4Vx zp~oseVY>ykRX9^30+uj=8JyU8Ftq~N_@{Sb0ZuT%vOMh*^BkZ~POacW+(28zo_^p4 z2EGqod_u_c$Y!$52slM2c=+zM5O=5B-NDHq(A(jdCz2kBLCEGfWv2p9wa;o%kHlDM=@f&;DD`d8S*a_a^roF$)?w?E%k(J3mg#kXD1P3zKjE#R(3f#39j&ITw1J ze5caqH0BkI7E+skh0E7HGuS)e^N^}jN%Dps0#$D2p^ivCLI^~XzijLX8FNSIFI+jW9tdQGRSw(_whqF}2Rte_ zbd_`qrAPPor6G$J=%yC>`@t&shBH`(lP~=v7S!ZkA`jMowc;S|hCfJHE>dS;5<|xO z3n31eN|$g(v<2E5s z!2`CyuyN3TVH$bye~tB}hDN!!cd*F}n4Y`4@QI3{?h*(}A+rU#WJY)Be}nfWS_}>3 zBXDb&FQV0d;KXY?#81K3rIUs(2T#-08_*-PE?nrRX2)%uKx+Iq5!jon{3R9;!AMxp zufy#lOgZx^BCm)pxeeitrlCfcVjY@_VoKHV%%HQJmS4Dxjl;Ek3GENehvX@GZmHc$ ze@OfNbSR-6{`{1#9IhRzHy4N|EKz*2NJkt>=P(a{OHH!g>2XWJM~!(8$BLoC5X_~d zgiZ^^IebS(Y0W2hQtSaxd{8Qyg&6rsrSdLe0MeEdf92ZTdKbrGn_gbV+O-)ISR>_v#w`PHDbvZQ>&IQuq7-EB?PsQ z&i}B_wBI2&{Dyu*tw@m}mXOvT%!TuXr$@O4>OaUa#5Sg5cU#>rx*K#C7KWD1)+tJV zl8H<6KZqKqPUAliBiIamv)2GE6F|$6892m&vm3Y*e5nIl|LqF^1_;XgVIrg*1hAit zU!p3oM&G3X#}sf1yu)_e6Z`Z>EMEoee@v`WW(FpQJ0SBRWWEN>ADU!Cz@V0{d2~ zz5v+|^84(Blz}(s8xOI2Fk*p924PtM5?O4`oAip*sBrCbt zQ=+y~#Ezy-CT-9W%v`Ge+`|Qb&k2!3Mijb&C$i+MPH=!l3+QWd&RL1Q5?H+_nH66S zyoEQivNA{qAI8mv=>*>V_ixC1ER=VAzV5E4rstizJELcIe2-x}9?gKvX{2izJqwshZX)GQ`Ul)%@@P3nR3KM=4PyaVr5#~oJ@UlUUG0P+mU_7bU-gksdCs1!1704>UW zehcS#isv6l%sFv5yYb&43G;Ih>gOfh*dM#^&PJ6C$9+M?XGqE_R70-lplPrbb~rllb|q3R))B{?Cu076vYo%okaF1lR$U6dB9 z7bR@VHTyz^QgE_ACdr%YetUCG#zitUR zwnmLPe^;|U*nDLz@^SV}fZERVwJttaMUu}YbwM*1?VK%$-IuF>U=p_SKt zsunq|w}Tw64KobV`;q`~3D(z5mf1oY304lB%Iz7`m+S$5m<4FT6HY*ZD}*+U6l9WY zC||)}uY^tg3f?d6OuJ)x)tz){f`RGmeBdXV{oK+{ORg4yo_kBmC`Lz{ihbbqwkC$@ z)G4oKg6QQ0nH2GM(wp`vWai%23|SQ&u2Z@Y{W=+bPOa2eSGruz0t+W6hY>C$G1jxA zy4^$*nAo|*BLG@JrN4%TQMP#)ZJ-~u!Dk2I&6<8} zrpJIeA1+3FtSTRQV+3GX)v!`_abMs_Xby*KkWZL`VG8!wf6oQh3m-eid)W4tVcdc^ ztM@=LKYhL59wz`4g`hpjeMd{q@QHE*ky0+G2Rj=LtH5)IY4qUZjA+>rc^HdCyoE6b zh5_^}V`dG>&b@hx8JT{BhQ@)}M1unOR!YN%a~b`z!*P%*L^&;Id^X-FP%AJb7Xu@Z zGSni@o(BiTeEY9^t@z~3%7b6Ql%5(^P<&4VJiYHN~< zjyJQ0fBDt>(*l1vbHxz*;Ry@30q0_iNLvaHfkA7`b11t0Olh1{)Z!35&O_R^JnZDpB%prX-2t(CX>B{VQdw$=Co>YRpjH^N9TAB$?LSN}AX# zf5DFBk_JaM9jWz^2In`^;6$>m;}M3+J{Tn@D0V94AEbuWB6fjFklPIlG8JyXa|sDN z3>9FOE9~fUE6-mjP}S86Fe=jBwdLyL1m#cd8LZaIG}Z@J}9UG z=~UhTCP3Nhq;*pop2wdUg_j-+c%lH|e>Fw=?}j_@&Uv4n+V? z>HtIeg@e^j0h@A(Zgj3;q`vNBgaL!a)V%8$#9}cIh#0~tPKlV*6X%5Ol4cNQaMzPx z$}yeRtUnh5ytSl$~c~}zPzkOOAr8}cJfc?(+|E` z7{OR%Q3fvXCv?S0O;jUtK~uS}fvIpCgx7JZ@ZK)pa+t^Fuv80E<;C*f8uR3pB87XO zFGn}CLDEUeG^2nf1Y`g}4-9^n{B;360jrlfb^#lIfFKYI0t^I>00Y55AQ1Qj5C{SY z0we+o0t^CN1i>*V!Xo23nY=xC1geIu)#FJZlr97GW}k27g9K;VQT0d*bVm>eQrwcC zdR$0TAqmZ};nd?%=#Zeq=qdIssc(p6z6+?awnzPvh4dF1Q+p7G`Y{Nn0uc-LQ&d5{ z-vdm42F#s^FnEANmla_iVmp0m12a9znlmidrf20~8rJj?Uj;QiGlo+F@Y2Wy)8`;4 zwd;!}GR1X@d|CQ+c<8nU$Kl(Q{t*p96S)~4#_*;Kr4N%H0lZm4A-wX)8Ves-Z+E0y zNI!xxyMG_OMo;22Qkj&;(NBFzjW&7?$zqLvo{u9F+Za8P9F}J;t@g;i)bkiMrRX^w zODK94imdoZ^z-}X<8IY$GY|c<0V#c+Pr`o0JRJ!KA8{YJ=DM~Pf#Io z<`mi^If!SoZeKlV<(C;LE1&Ve+Hl=iMJ@l7ZwhCJvmwKfDW5sQeqF`Dot&Y6{2QWB z$RZ5=lMl%tlA~gsIw?zMs`e8E)fO?6pKOQQj}k59xXFbMKbta*HY9_8Q&S`gF9Q(@w8)y?*5AT-C{so1-f3Kyjt&$9g8 z*eEs5#uF}_hlVFsggS%9*X$_)&iHRdXvPZ*v(Oe-V#yBUpW>Af!L@o9Fn+Q@e!*$$ z)0dV%%|?NGa2B6=Ade5Q`>FM#ia$gvk5BPTs%?tr37f(bUSyhe;wf=|X?wkDl^Qhh zmIg3YE`3JQ^H|sz%Rf@CgjDen-WtM4GYJpZu!LnqO5N{SrA7qABP~E+$MUq|MKMav zqwud*@NKUkXu1o_?ZH)b@tVtT6riu zM7&Zl%%ma_f`=DIi7AACi0TmGTO!mg-~&Yl!e=;uYiv{$|@%H4ox zXGk4EV2Y$pV<^Hr@%Xid(UmY}$OcJR?EQTczHA9Ax{m1YLxs?PQE>f|SHCYCh=pg- zYCOLu6-*Z6maJ$U?|V@ogL5?eg|qfO>wqRfa&HZV?+4wI?dI=;z+27KOQttS<3*~N*Dv>pkdv=n*H9>`| zF^XV84Xp)zVYspvZp593fm>jv4Udun zVM-EmOK3TNy^2h4&zc}Gy0o~2M7c{cm^B8v-L7C-wAac2`}MXD;+V$<-|W@4eax`J z_1dYBy}?A0UKZ1;ETglXj5xJcJ;)XEHVUcrwE*MQ9c*8Fu)Y99+j?cha7Q9LLr3^~ zL{~s$di1roj3F%y8==}EE=&NV1*7-02OkHptJmg#rakyUuko)EsZwf|QZHgSXHwaY zcFG70$)Qr&3hmHGh@>>~L(qPeK$P25N{eTQYDDFyRD`Ot#}>LL7{n@{Hi!fZ$f`gz z`;5U$t*hQL^JOn0Rw5{?##?qc2P=iHTuF5&WsitcE)htnf+zbsw5F}!*2P#bOR5R_$&eUcyq3(X40lr+E}`jEAOCZa0ZGmH%@gqOWq z5MRfF!;W_-3%6jR_3KL5fr3v5=A1{?gB=HPYYx0NHftOaJQvf$R?Pq+FPYx4=yCGv z402BqW#r@7^>zFQ?_~J97`vwhV}sSQqmD1Ube#~pXpHo4IiKr1B}$w#)-pmduA4qWoMV1Q_*%GbzF;YSkfPwNomsDH zgWJ}NXN^8!wyq~Ozv^6P3PY7y0A5OY0%%hwRqJyLa-mU04_2AbdWFOK>{-uR%boRq zg#~a!y*Sg0`};&zfaTCohXEq%I1p{|BI`l3hxH<2C5hV+VSTCuG+U!`VHrqQLTvNO zC>TIJTqAs8FzO7`;#U6zGV6h9cCA*QH8f(Owo}#4!s>^>=F2eN_G!8C*6`-xUMt%V zDaBBGAM07AqUsAEfQICf<&Ww~hRx)E;aF9RdT$RbW63yl46{>*2xK8i+GOLK`csL0 zO)~}=!@1NULKuls)|Z)nI_HS)OT3Vz>)R>5B#i_!UmYbhE5uk7Y)?^V2w;ms&|i@e z^+^dlp)EafLat(X0DQd(>W>Y*ufgcUOWQvE5xT4g7S{zneb(SsgR4Cy*;Ociw2~Hl z5Hf6wkma=YW>$DO{SdT#OE7C)deh&6EyHy?v_94JgOCNcXkZPFroWv?ATHdlt76_& zYZTK_9s={Ru7Znom(B@cst29SfM}$n4Aul2u9)>*=}jT*ZnhB@a+RLPFv|$6VRYRQ zr5ECukAvv6PR`Mn7c$i2Xu5)blcJ^^w2EQ|CU`IuN4k`>hd6LWe?g>kANlq!q;G#S z6VfwTTjK|$*J@Y(Vjo+3Jvv6jS~FOORpK3ec7Y0eRp{e|?w@MvHF{n-M4Uk_dexU$ zxiES}*aC^5@B((FH+VsrIN@tzSr+@%cNS_ZISMbAZX%#c4&(?mB?Ry-wqQE$|`0E<8ZMX=fI7 z^_xbIP53lpv~CTtgL~~!zqG5NDI#;5Zv8!sl7Mdh^03ONHYxlNr&vjd&hyySNtfY> z8wLy3G2C{m(&($DNVu0?GLX48O)dA!)I{L{E1$IKru z@5F>B#azv}xXcoaznD#Z#$8Xe{vKT{IoBJp=&`_%d&4dpeHTV(foeEle3`fBVUbyQ zhZo*uqB#}~+a!fFAJfG%FP$~bJ)&(P$zKn z97j%+fN}JIKd~N|)Ug+(kk{}5=<(W_kcRL?`>-n^;T^;MyVQR*>+s4n7|;@s_Uk+c z0N$|EVXrlRSbtT&$CPbSAMyl5f~d-VM49EV$f%eUSV)T`aK@-EuHD{+sAoCowU-31 z;fqlim$!(iL)I9ZlhnAW(j^MDVP!!gqop+@u65e9s}Jp(ytc<|%YGTtr4kv^PnciV zY#Us5`(J(I3{2^Nm0p)dODqY1I2wY#S?NiaL<&rQlXN5j?nqu{7`B9TyOy8iq$_eR z^lm%2Ns(IHW_(jp|5`Yvh!qw5_F)@BT>~>J!0tmN^ySq`iHi%JoQi*@nlRyy#qWEl z^Sh|COl+?fx;eLVRd}9SQPa8-&)xR7EF; z@|t~r<8${gjYZef$(nDD+F1fU`JCn(OKi*_P^D4b7%?Y>*vEnKqXtmywpY{{q<5d9 zK8Wx&7C!*84l-Hvn?&fB%#~#xr^3pMOxPype0merhqQxYcF^5Myf2z~j3+2`FDhjk z4e?%fSBg{q!@fmF5g@7fW6(hegdESjA3`CA)a^fq(!K4Q9~xpJuJ zA&2gYJ1Rq;#K4dbM^!OhCZ#VJ-Dm%06@@q|Cks#pDL%dxC!_uDam#PNY&0koh=k?9 zNor}prQQY}11gdXcb$*GznY6y=-Hjc`f+3^HY+OIKq!aS(#ckWqi81hOfC{sS1|m4 zl*#CzdxxS)MWXH7)e}}K!9UR&kwDmJQ=-cuF6ZELBML#9EFx5!o|H3mec`9e%$vi_ zoOGfQXb9*xW-^m-9p1ZYl*quA*BCCZ>C}b>Zv!LF)ab}0KvqEDPMs!q;m-w&UV=Ni zxw4`WAfDWw#LWEx5t)xMogy|g5<6;tjws%oM4>edZEh`X|7FzoSXYxEq22>&34xD= zfw;WQs7n0SizO9;r@gw0c1jjF%DE z%xacsYgOz(5=JCWPVl7#mW(!C`ndRcBnqz$4GGqiXWNnJ)){yqkr0LGD2xdVQSQ)9 zSoT{2lwSbYm%fDo8-IjTxWlhG6M(>gfB=C&Fc1Vn2m}EH0)ZeXfFPj25J(^p3<78Z z3@U;nG;f~9gd#EvBRStuoDAlX$-o=BDxCbU zk3E}|&T$bQ4AN!s=7DmOSa9IP=O*EB12iHwK)6Zc)O;`5%zt^IC{A%3G%+{EY_d1Q z7pWA{z~VvFQv3%YcL^Jzff}WBhk)&`8}6K`Nr`|)YJw&ro#_uqnjvr{6HI9$89tgA zh>D`Zhm=xj9Z)VB6QmQnp2@M8IH)HO`Hg&p#|dKc(E!X${KEnQ!9a+*8+2H4KbSb1 zDar&$g6e9|&wrO61M$W`}GJoa9#;C>L^*+W{~-p;OdI1qR5m8zN8vY4jB6qFuf#YM}CtVl6<#I zzKn@3TbH1doVo;YddQV}1}xmT1phcGupfnUfJ+=9jDP#^mUvUm<(ie~F&8`tqhD}= z)?B8>Z8j}d3^Um&18=S zFTSNDUw=+2loPPHKT5nJ=EO8m1{oz(vSY~BgGX2>!N;7$K~qOLCE?bc7zP_PpFIh9 zl-VJ8Sb~xy+gjqD=9+S3#0X$%2V?Vd#o8niY9vf!N}{4gD|awGlJ*c$FeXW6%KbQN zLejAhSJNQsCfKeZmM2?SkrYRiAEc^vAhlS zLlPyOS-bYqhR!3wzZ;u6k~W=Sj)YZuI+=j-7|KYjf#*PUcb)(w(;Zt=j3wVPD3We; zJD*?cNcNEwakgBIWg2>npmhN4M>1~KCn!%iO=|=| z;1;mMTm#ETNJE*z=voK5>^+hKTyj}^r0}IyrTe#{pGSaFOW#3hFR1lHDpr1qM~rXg zYOSTqp*!-9ED*7TfHKXFJZatFQ1Ezg41XdO%t#JnDYcCxlT;{Hq=bU>jC46wo;Y$W zOGdj127OyenyV>f{qIcaD3x2$9s*kgF=ez#UkWBIf|uc>;gK?f220sKM2X+mvappw zslkx#8EG8y3f{8K$d;sBssBi8=|>syN0V&}esZKBG7?;&ra6*EIE0X&))7i<5PzrX z3`Mwq5qJ#ItDvKg5nm)NyGeovz5>ax2*`wEKQ4l;A2w+qOzesybE8F(Mj&|>0hpXF z_7*suV3BsXfgA5^FkO*8skvU3OUEiQ5IOBjlqIuN#6E%uv#RUQ7gG|e_!KbD9#hVo zCw|C6rVx2;!KDodt&T zEmpL&e5<#BNk_&vWq|=AW&-&i@;5m^3^{E0{>4S(#LHShSo9ID(XWXr6HLOv#jXdz__Z4-izD$$dslX3Cj zXGd@oBzyoOA=I!zv<9&qa(&gY(*hS1j2{o??*ZF67_Xhodq22N5kd;Qu)iiHNlc~` z2$4J4hTeY=HVM@XF+R_nra3|nuB(t{a+)mT@wk}H3jr>h@1YTq zcFjxGkT_9de@nHWPBF@&YmR!JiBf`f^f4HfNXFUaf$60DXqyKH^r5RfFdex5WYcsY zEMP?UVnm$|tTPM@yif%nWp24;kAUf=$OzbtM|ZU?=CRx%ituALq3wi&qvIDoISm7qu&>;L zqEK4eCr`Ww>2R;e;BIeo z(ZVB#NQrS$7S!c=><3D~=?2g*)pUanW6;xc)6pAak<12SWK2g39Y{zAC3V>cfID#x z6{-vN@qY-z?1CLO&%&tLXyl+NKOg z55T3pUvKs(|G4!2&HT@&!(F9TQ87G19Y>hK{b5FMG;3_pq(4XJU} zh=2Ir!WY({9#n1|;1|4?gDWQy1w{CG5T>0^k#7W}82rTCJGhnU5U7Hm_X3tq%-Utr z2|ByuX4}r|MbHP`f{Sk)Viqv}`#|(sTfJ{r%k%zznT5^*j1opEo%|l{xN^dvbsFM* zA5{UC&v#L0CAWMJ2$S?KijX4lJ*&WRBY&($XbKdKf7ts#fb_}kA4{y|o-C^*(R;2& z_Amcwk5W zCKi?5_Nx#(;;BOQ@3?1wVVgP%!#LtT)f;j&+?Tk-!u|XnVn74;KAl2U0FtT_BCCEF z9IZfj`>+U}wPcY6Sln$N2xSwbeQ4V-*Y@iQD1*?!WYtid9tsjnlN6nl!d`o%3)^Af z!UaSJ7-GF zp%857zc`dBt&Nsv{d)v9j5ZoYgLJW;{>q5luwIBAO;}IvAiW)6n=y8ftG{Nx2Act7 zm$8unb$@QCB>yNv05bp(5I_K7;=+z3;+}W zB~vyRq`jKz zcx)>97lw>!&FzHJ2dy0HA4x!$n{OfKEEX=eg#mV=7?WJ+wu>%TIS8mgb(tQ(p@#m$ zKF__{&4hn=r*afjd698jx$q8Zvh=kOPG()60hOC`&=zJ|Ip(Bi9hLi#7KaBnE)C$B zg@0vm@+t?`%cDU{ZkG!Te3vN~Z4SRk!Re+ixwzv%p0YLJ`*G#IAvoDIkotdrzA$9i zj3R&xk{pPUcWEcYcL&Pw9|K)E896Dpi43@L%sDQ0$ zfa2*I0N2RJQw*@fYu8>Tk?tz(x6772UPMU(lpy0Jp;;k|_QXR@>JazP;f$30%Y8*z zaM4LaEnJ+1E{6#vunQRAiH77SgMaz5pReTi{`ddD04yr@YYBp?bBHIybQ-o$2Pg?5 zk|@tIE+KOMSg01T3XZ6u%uT5ajJ51B)%xFFTiI(YqHCgR!4h{e~s2 zcqE9uvh8(r*`XZe5%abZda^L$)-K%K);81VgL4?*8bFckhk9Xc34GRs~70|-fw z$&y0ptHiBz<47yhG;dAB^?$OQh|{1@H5*_Jj%q_3h03&uY?!=Gh`Yf9Ic9XQveVFW zT=VbuJt#WxF%b?76%7}vZluwqk2D_iVA%|Z2va&-Ibi@ikKU(kC`HNH6T2z{QK>uP zR_%&Vex0TCsQ7|_QCbvdPUEUIEYEO0_$pF5DZzsxcyGn4S!l`L(O^Xz=Ft6 zF5v=wAKVcVYfK;y4&9f{1Z!AuxY`I6eYh$ZqJY%ic(D5};W7j5;5IVCS@;?utEIaF z;ga4@71#ELXw{JDJ|RUMmKq%X4-do6N5<`8VLUIPxPuGWFS1a^VHu}uBEo7B8Qh7b zlgWB<52~5OaX8w*SAPzM zko`^f?lM3o_P5bxz)#GB-M?havd8dFopj|(N|@9TalHX*)*HCMKLKXKz`uk6iDriv zVW4r23sJlQ*USrWVeeFT{&%e`u!>-%E^qrS9;X{>w(nhG#(#yYIV9x)OBbEv zmV;2=OnAQvuG-M6bQv7Tus0;QQr|WDvEUkmKN8J=TZ#?TdID~C9cUM#{h(oz0UTAZ zq?)-TAO=hrj&w2x4J2(JHxh6;pdCNY4GLnAz#KA;SsO5gNu&8`=!HO1k9M8uf$x&Yvz8` z0krE{k;m2Q82|}Z3Xl;B?uzfI)9*b0DQ ztOWA1wjF`tBR#gaeCmiQ!0e4QS46iD*W0qLJxIB27s@IOT0i6cS#RBuP~)-k3bt|R z9hBUldIP%NIkuYb9CUC7xm`YV)V#Sfs`b$?n|sinytR+l0zyn5-1eKl&~D`&k_Enkswk}hkNxC@ZLhMYy5g#2s~2>iLjewd3|-`njoSM8A2rZ-Xb@*?+s+hr8R|zxEJP07&eZLMvs~$d<0k zJ$kb(7uG{Z2rwVkpC9TcW^FG7j?TCsAB!Ihqe=kwv$%op;2v^ za4-O*U;yq$Qy(w@U_skXdhR#WIUud{e2FX#^O-jtp@hJL67pLR7H!bJu>^!MxXaXl zjem{)NbwTzw$ZtG!BpXIkD_?6U8{s*XN3)ZA!iZ-47AAgbhBe-77M4LMH_4o51ZSR zg0MwWar1u~ca+u%w>k4a#ms*JjX#Tr991x0{{JMJeX@9<{G==Y-x|2o5erDaAEs*5 z>6-kHnEX!+@ZemM{|Au#|Ej(GmXQB|l*g0&2gUhO%N7frECZoz;P@Z@s=aCU{eJ-f z{r0s;XaYJT+peK55P=7FCU|#+CSQIe8t^08L!llXEAuFjY0AF65#g|WO@d~I`T$RYnPz+*oK`O$uMZ! z`NVB4ehWfj7diupMWV^*d6Z~8Mt>3&n}Gje*@E?@p3rrOsefC0-C^oknzTPAHzOMV zUXA&7rZd1?HNGCPhTgXl(uXX^2EB$5Jp&TWWe|vH{;5piO3dz9xkDTR@dwz9A4H)k zG0C2}H_Y5SYu(C-fp2kpON{`~Bj&Auj1W_n31lRRJApq~)@Y^Y$27$6fq(QoWImb? z#%Jt%R)hT*VW@;ptGUrbkTe61ZWNafVf;~XsJYby;^b(VG2maaMsO^dYf`~G@>jn9 zVH;umLu6}WdLtdv51Zz2EHA#yMd1+Fb8&UW#cHHJ*8<{q-p9&^w^ijou z6qQEe*~Ls6>3ajC5Ryi6Mt>T?L>fUt8i4}R2=vhikVhkEhsMze?d!94(FkPGNCMa? z>r9>Ed^|`JjR@oMHMxHSFd&3R_OHu|G0F>K3v&Az5vN)yF787u?#Ec%&(bSM#eEN8 zSAXzigc`5-Z;j+*X$Fr~&d8Cj`j6Cw` z!)rFvRD=^zQCV>gBel+^ZQn@4h-SjBHBe%S#dhg#wJq_o#zW2wIA@CTLJ|C+FYypD z5&scJ5JUfrSBj>8gMX3d8jM_Rl`&PPGQmnzdHv3_1v+t!p3_}NS8QNRb&{z&W%V=WL? zJX=woBp9k~r#P@a6!M=a8-o`HWZ&S&bWKi#{OOM(Tp#4SmbVqPUorHs^UFZH>?XEK;$==5CV-S_U5xwIQAIzjikt!2D}L>U+UYP3 z?r8n><8nCToO8b(Kn?1=Dw#h*xvKb*TDC~&zXdCZDxu6j9b7~`d(5r2GQkYTvlIYj z+XR||wv5^uU4H^mxzn}@X!K)C8{h;bELb3VbHS9Yv;j}Rq0v)R=EhR@B-3GBOLol~ zOeu!8%sRpj70|Y}gLK7bw&DB%0Z@G%bQt0~2S3Jq%r9X6mFgM6b?*RjqRZMu2se%B zPSX>|3Nb4*GP6-n0ByW)@XGAU3O5?i0B~_2cp<=p@qYv`*kh|KpM-#wFIz)fl;fwj zZ1q|e7E=g31cJrRf;eSGl33gj)MS}d zVEBir0k6?RswS*`2!je`qz|qJ6ZLnd5MmbtoU}ej zm5ElMW`EzahjDeX5GjLf6J_F&m(sjDG*75`K3jn!R3fTCOeq1w!cYy=^T<7fIT~9H zjtBYXj4k-Ol!5q)VDA0`F(B^P`%lhoCIAhV-cnSrWk!GMyp4@*;>LE+qkITsyMsQ^ zkV<2VR_y6%VQiGr>Z~ScY*tm>Qo|S+LS)2}z<;zQLwXVGc%6UH=c3nuSmi6fgbHj1 zd&J1_IQo)-91vpyVnUk1^#wGB#6>4EHhjhmV2f029lyIk+*rEjaqJ_`A~}>2$*}@6 zVb%CXYQcV%t}?~>T5CV8QQ<%M$;3U#E(DbI)ZolY#I1_e5qV-d0hGAydokSsg65nY z%YV?47NfYQgiWv9jr+j48`a?!2O4CwQ&xSPKz&`hd$>>oWm{TqhQqgbKZT*7lcink z2$vzsC>Eh`@W)&DgyTiGHcdE^FnpMB@Hcmxgo7X5M8ff3U^|R(WGxLqqzFeWh-PrI z`8IchaBzU{IcyrM+?mD)2bbd|zQ_VMoPRnvk|zToI5<8eT!0%KQPlU$8XSTe9BCTw zg7i%dk(hVZuXg!+%u;+v9$Irs6&$R87so-tap>_SZl5C~l?|40qfXxdFf?xZY{^4L zURY!A=Hn6nl@{M{l1cRVcp^6{_ruBIBJ-$a%`axw6KXMnQNPhv| z9v|8EcmVMA(rK!3Bnl*Oc;4db2F9~Xw%uy9%7!kEH=1)->f{7Ay=1UlYk}$Z3~&V> z1rC&jdx-vub>q;OKU|ErT8t;!Scq40Vtis^ygerDNQ{4S%mKzfe*TV;PXd72_Y-4dfDL?=Xi?;q3;;zKY2xbYrP%4<|#i|#$OPG~`5S+nyj zSZl?>%8Na=7RT@72&m@Z&c7oJTQe#*eqKNnuEq)bvQ{C`v4Phi;7u9N#sU4=rtGUv`0IBc*C)XI5TVwnh%M9NI z2)^9_JdU{~?;$!>i9!d~P_HmwmKfnF;^bkkR;_zO5Usr$@w2c}^@vpoxOOJAgW+1H zpneNHVAE&+4}jv}*F^H;34f);=X+9!`EzIzbYXfmXq@59_(Moy3g*2)4#rhof|%|W zgLW(E01H||2qfXt#@Jen$w#F-UZ#KgHu(GiKr%v$+tupvQEiHyE3U9?=(vHAAleEq zn)j_Pi&UeIH*>Yl#Ej1a79qIHma+!kxa zt8&P7s5SziYdoz8_8F-WlOT*7xUTeNUHip!_|SN2G#+i$gcI~J^zQAaMprObdl%sf zbDd%8DISGWWR-D_aDVBanQuEm7NS1xHp!T7x9!Pec(EdGv@6SDb60 z!GtT`6NnF7@y&iFzZG+4#*=Ra6L&<$9e#QO5BovsC-YFATOk)`$}&KM0vE6J-7Bus zU!sc`->bb2-DZTW6|wQ@?hbI(?c2WBY6br%Ei?dK1*-8-Lw^9fmip9Yf_5pr`m zYukDF08hk+#81|-z*$*IDugpg1TJ40pw1IeiE=XtL4R5Acc7h-pJ;4zsOxel)wx6a z{+A*mq3PKc^9GOz3TAF}A1HV*T;f3Sdk(aXq95w#bW-GDTFFbLKDU^`IA7bb~+pUmlQ`kNtW?)mfu`*O=SMqhIaWXLwvMaWWdE-X;$; ztFdD0d_nmCk;I)cKYCyWf&pjkinVMdCC1XMb-7tKAQzA2?pdJcCm`yMHImfpi`-WLi7%sfU_W&fvh>U*#+m2b*N>8HBSI^Po`et znH%_3&4M#V1);9y8l-ezkZ48v*uxYM^_%1<5I66NW18P$nitPw2&Vb-g3M@+m|XL5 z$bTMNphV8*eu?(<+dm0Ill8v*NnlQ@K{n9O((=v4mHHgQKefZVh@zH>d%$uMc>g}! zIEjz*&qplg$N!DU2eZlLLxWQqa!EON3>jMYk!jj=p%q3CAy;hYL1P3kFQK8Dhe#U< zK&6Dm_2Pu>HN$isq&5#E5Z%E?Ur3NEp?_)k(2T6oBN!brm~91Z6dJxDrKUt zWr)U2ClZmR)$YpJLHD?{Ci2_DSS7VB5F&ri!rw*kc3)80?2W4JA}-BXf>;`@28&*&;~TioDZ)QqMfqtIDGc@^}q|-d+(fQR|E+p#Kk3F zSl^m;Pf*lWsr`W{?#9z95~KK|v45RHp`mbW;#OkQzGdOTGaHVgb};E$^`QteVj-77 z(AFoVPs`~PS)##|7{#CRsgWr*sQc);LB^ML$DA&MYTcJls#{vR9`$3GIC2b~y~HDP;+XFLxouA3k%7~jg){`}Z$8t&SbzTq{ObuJ zpaDu8J}uA&X29K$x7YJ-02XQZ^U=?j%gS2vkqub)GNK$;JA~o;qFC)-7QWAIOBWo~ zcL$!Aqwpw?e0-!M{4yka_2dtJp@|2U8(nna4F39!mzm%9kJoI$kHC=^{}TMO8BZ|# zWYYBv{1>deau6O)l%K$J!hfQ_9|k;C>>lNgP`03%YYz(TdKCujQ2yxO#npi*#Wo?l z65xdYa7t0H)nh6}xxCrsX5ULbT0Mtxh(2nk0}Vy>Q48=H4%QxKmq+;TJp(cI#=(wD zo}ayk_oOD;TaPV$>z!f@NqbE1M2E?r)mt^$+PZ~-PNK8OPj)tUg@2ML>Bo4_+g>dW zjs@ob))D`3e|py#aE$xKz~qZsx%&qOfO;R>jz%{8n%&EGLiQ|6@IQ3tg55cdzSXl> z#=0Z36#C#*_agEmG$F$^ujfy?f$rjDu=`1R`$;!bC%UB9Zvw|V{KQqc)6W|KC9{3f zK!b+0EhGp*^GP%b^nZ;q0Ts^pWGrSgXqtlokSW}geIuXmWhZlb*K$DBPET1m!0NmmZ!}Mc^fne|F@0N#ZM1hrFLx~#h=-e;< zX8X5JT`|HP+k6O};GD%8YvwHr+(CgulL+ikQUvV9*?)IT6-}Zm_=F(dO5;IxY0NKs-62e3}g&HU@tB{71o{xvAJ^sxDkY#x4s26cj|X z5P>4b1AppwI{F{FB3XC@f}Mw(sCzVXk%U5-zIBD51Hoo|rZP7`Ufp30P7jQW9@sZS zrYD#aX`1X7pXk-#fk8sR=iA?Tz3c`0EjutzDA_tN=vo4nD}6M@qqJ9vE5mXJd|8br zdMTTB6e&*fp>uwa2cgAefXoVyAbK9 z%BM26OwSTXeS@(@eM6e1twpq_?ASr$a>8FF09&3*0E#Wq#gJU~JH0Jh*0}$B{ObNM zRJBp_GEy9{`%f)#_utzF*L=`_Lp2a#7xzCEe*vdG=oQOkeVV@yQlO9-=Jo`)jfR|! z5q}StvD~Bh)zJpALt_YZJLc3A68i3R&=N!WpOJsSnf1}Jh=Bm&SW(mgVaGtd&-&DL zq>l1*-}~1EK_9{R^pkU$&_*kqoD!7aE05so;!TD;h-Oa~`07G|uhB~P>-z4%?qY8Ii+1j+iOyY0&Rqx2UACLMgw0(j`CUbGmrtHu zn^?%*oSnKnHGvhKR8SSEk7ROJ8RbotTMyTJzzwW6RVh6i+^m0C-+a&F#8{Kp4g-MC)Y?@#@UR%KQ^ojtLWV> zx^CM{R3uEy%`YSBT@le7s7Zg$elWx!z@-8Mcv8MzV_3EetpDd0N>f}g=>&|e*e>R6 z5IP^nC=Lm(U9FOatzFt@A=kr{0b=cXVC`yFRl?OSKX!ngoK|qaWqg2MnSbXQZ8Zd1 zDvA=+Z`x%GoJHaSOhk-zSj2u&Foe(ugV5+OeBi2eal)*BgmLcf-9#BNnx3Ffk!wHC zkyd}Kw4^+eXjH&Dd8)nxjr11ZaZBJ5yKz+%xXSPi2!t>w99UxTI0;sBLe!7cYf-8@ zLOlGonm?Cxhe4*_?dM+9mVa(Xj!@JudzcGw2|&2GO3#nlZt@3GWQN3Ipda_1ax&y0 zwMFm&)-qBm8hAfRwhDiwNrqlv)4Bs$`BKq^;9CPC$Cw}$SO|sHtR&5MseX-ZHGmWV zZK6LQ>#83J;d~+*In@fWfE}{#Hi`@9zwJm32RYNzPpjf`b-U5Frhn9OP2(*wws>Bbl2iO2?-7A4$;&cM=-5mT@7>s$3jW74?X6TL)5e+}qePaiu#Sh* zk*$n|MwSX{HJAPVpMR1LHMVrS3&kumwmiHxL1!q+Y9$a6Q->|Z;s9~jlJSNu)Hl`{ zwo)9hKF+loEMN_E{kyOgx3G0Pp$g~}0V3uoLK|}F@6A~ew&W1DP7t=r54Kc0%7c6k zwlHq6^|fq9!vD5o5|VVr30S}@EulUGTa|EUD_GT`{(?Cc0Dq3CBH67>5HNTpOc}vu z$HUP{w-)vMfkU%*G`)?Wucf-$QNGr*TD2K9(!ssf@&B%s3r1Og2akvi8Z_YYzfM5k zm7x1B;`I;XwTixL#A{(9+gAf}jc01oVE{9Sk&Q41YQL$o4D=7QwQIGwf2A5>r0q6g z1FMNZ(M|c|Dt~LmlbfPzsXi_VoGLPqA(T*pPj?(&p)dkon4u_(L%^a1Z^`N>@Nrj* zpCfPQDWTb|b3PzyErp#va&*5xDB>d<84Yo3EdYgO2jmn~1q}^g_~|VmFrw9h?H;hT z@>B_Me{8i@cqn(e(|T5K47M3A2QaN=0A)a$zwub7_(n!y?S6lkm^r*poP=mHJp3%z zJ4A@2{l%weTb2rP|9Zh5Nl|$HrHEauejpon6F70Km4UqV#ab%!f6A`DXJBC#!&=@S zMg3r{HB0OPYtd6&)_=9C_g72u!;-jOt?)wp?`nOb$=TD@62ba&wE}!V7M}5X3f@%; z{mp)RVAD~=$WVViqUHU>&}g(t^++e*9ei6kC0}~7@WB;^;ibX$=%V}}I5+v0ze7N5 zuNQ$Wv%SsLW>0SmZ{Zv+nqJh!>-eI_R=^S?$9EmTwg-RdZn?NaPr-X#da-H>L*`m1L9Y!xw-1@S)nyGQjY0 zB(zy8A2;{?QC1$;@KMg>l5KeqpBE7!zAXc0^=0^aW7mfU4}6~dVLBs0wc~W5o#L0> z041D)1>t`b>zl?R6`bM)f>Xe$a!DkSdo(C7aEE;L-FxlmN&LIfl2Cpgev~wnynKFuYQYP-f z_9nI~(}NR>WTQNo4|z5rctCpl0w{w!_)5_R7QGI5xIt6%9UiEUqI2M~B_2ifZnvbM zz~O(?oZ2dHQlB)PFGUzd z3}{)15w{F5wy;9R4e)E`>t$Avmx}&}LH3&x^?IU#fg%9^gCFq>1rJliW-}C*{tZ5l z3`J~Z7)H?H$>$}Cxn^K^&?e-0UY(Gf5Qcw}aHH#s1_iP8perco$0*8ckJBTRmkkto zgg`)b$DTFG@DiZt=1Ve1P?`D!I^#6x5U2+;wI_x?XYD*>AO6a6bO7X}y{u7cBVEw>q77@dD}KY9Y!oQ64i5`N=)0H>D`!OWmW4;SdZ(3!}< zDLpK1w&U5$@<)Laf9V0rt8zcF1JE$Lpkjg>{vuRQOq2kk;ll*(&o~N>i>};!9HPKO zH4yGn>tbaX75!lE1?NkY5sGN}5^;>|!6HkV!I$W!|0w;jT-DJ?CfcKXsgZx4M*&p` zo&g*CdtPlE+1Vc;`3x`NmQyBEu(Sd%j}NS{%YQLM)8ZA5?RQS?!^e`q7Z zukWYo&Jid8U?WDY0AMB({Z_Oo?I$7N7Nn(52!xcKAp*sKpo4;~OcJ4rlUpz;=T$@^UVe zfj|vpDsjhicL(tVXvNF0NG6@_?WP$UoYr4-4R%Bt4fM^gXcOIt3**zw^FUmbQPm~K z>=Uq9mbUE)WDNO~c!`zS;v=E|;~WUMTi`7RVuo?yO5&&V@1leG#xQqJ(;Y!G4CBu# zZEbl$yb+lQRZf3Th-YO!;`J3UoUb<*{gB2NcC563a-RB{Hs>zYj-8(nj{z09AqB$I zNAe%Kn~1ra5V@PKxSL9FHwo+QrpN6jL)%Rf+fCS7yXl|IHpzzUdr}R$KzZkQJDuuc zbRG0$NpY+n%;0{PNduuP5&TdGBDhIdi9Z<`2R2uN_ZxpW9QY11mb&&L6tv-&UJU)8CLr<{|g*U*W zA&p5_G}Y^O5<72D#(F2g6On%uobJV>{QX0p&>Vlxq>ExT@DSJ#4T&{gP`5>mq2nUg z+l!O@!8U)Ac&&He^J4(MEFuDPF5Cp+MGeK?@e{Z=euv}XT=lcuRNw^(BtL(l4+73h zA`&5SO37QlMy*8u$ajEKZ^-%i_W2Rp08`b-r?86Ovk0xucd}sZPbyh5+u=U`@ks4B zbO1ldRm67a%p;-UJ#L!40*Y=gh(lT0=Wi=b(4&9+&{|2<2mQ}Vy2d09{1Td5&J{MY zGbLe6<$;bv2lh+VB8UGFUTFLTKdcf3Ukw30tpVw@VTpA zGb`SGAMd6#VDuxZTNv=cPYXRbuZyBQlLbkslE&5&f_D!rA8H;zn$B5NhFH5aOqK9{ z@iygwu3wv$E(qJEzY4@Q!E_SFyDkhX#Ihw&woOxQrsuG?O|3Eq6o9g2U{ zlM^0q5HY#2Nq4<_S*Y21KiRk&|jnB9FYN3 zL|QOV0kKXQ7m>Qiz@PiFQ5I=4yoj1RA~XHo6 zeiY=8L~13mDfQLangR=VWURH5h(^x0-%gOlh^j-)EfPL7Mn12=o7SV+pG-bJRPp@M zri+oepDMkHVz{q3iKp{)jXCU$8waBb80RT0c4Mk#>}!}dw^>}i3x=lx005tt4uvu5 z04|%s1=+!H&G2?f6#GrIy5fKJX=J9}i1G?_MGl8LPv?QfD!0eKFE#oTKY1<_(@%s{ zL)oWb1GBH>{l(s76!BOWg@1fBM6yv@WNXxXCb?L0Vdw(IiJ_a5*JGoXZNT7;ii6r1^hNJDgM*NEA;y zu^lZ5X%*>i-|{s86BsHB#11r&DLhVh54Br7cyYC&5CGGz8S+JzrBS$6Z(j7q1B5%KVQJ7Ot@=Xtk z%_E+Am!COQPsq%zBr$&`K& zlMK(~uQwHBx|h@KLNw4fJQQKI>BRNY&c^Okkfw%^BO9?3pbiE=*TPuJ@@An>BgSJo zLA1?+dd(M8A&QCT$F|_x6QFcgXaL$?L`oSCTIPu%9DlfGJ|q!CG)N*FQU>oXQ5^gP zG7jHi0fub=0y=-bm#LCAICgzMZ%wVeDb)>wK~HTSdd3^v-g&+f0u|ieAp{Z+>CnRP z<2l{f0}`oo-kDb`4~XOr;bX?K?4KD@tMCqL6~Ji~3YFW2(ke(HtpbRqhIT(#A@O+^ zAc%l8S0geF0r|9`S`Gq|LCR4XZGxwnPRLn`g(3VF_Ugoeu=m4*^o> zpNjugZWNz>6rlVl7)Z7RUw%@)L&p=Fjx3~~vXFn354uvoQ`+m`ZPaaYm1^J{i!pS( zZHgj(8h#d6K4!n_afkK#AX$`|6^%TdzH|@RZgsUjH=wPHpA$4G>&`YKa$N$NDo;$w zX+nQwmui=r!A2AUe67nsjgTiS6A7;rP}7b%s=KH5=%)YyRy*_S>$aI52wR|Z^02)I zD%pPmU!}xmadO?<4W3`i%07&6b3NQH&$>$LB zSi?*ZS(m1<=CvR710Fw^Ufi?F)P^<-9zHJbH+71gxw>7IqF{0ZS8YIN^TwX@H$0#U z4Z?(pkyPmF*G-6E^Bbco=sM#Jn5VyJ_akjrH!u?+HoS46# z!iw{T5qkg{s+HRjdA1g^N>ct2gG5u8vge>sEVr7TbHa6mR2b8@XMskOpBr;J_J zO+^mE?vP&Qf)C<2I!82*&J7NvbNiizT^6*mmIxy`eXV1?ht<(4Hmei-gq&EBBRhZc zCmM(#gMkP@&}Tr5pbh=$9p}n%B8yfCqWa3y)|jvvtLf~ygW8n5Ie}h(Sl(QXV*w2s zeT)r`eUZV?=78|tWpBd(E~#lDtkv`|#2oQlH(1Tz!<(G|Ofv40^}sxA05Q@& zq?h5s0YD(|sKYPmQy>sXATX%FXeg4$V(}~v1N|c)Fd!f>5C{gBLBjzVe?$)N=(Yzo z44+-y%E3nI*B7%Ct`7fqHN$ z1lD~m2lGX)0XX$y}DG6UinRhs^8L4gQyJIBLNLR-*s$XK@o5k=nGFO3iTf5EqW>}vxG8eBYw zn%BXiml^+`4i=?*E_*m$EEW_yIYKe;pIY6#J8rObQO6Ah!LUtRf(CTDZysua;qjUE zLAw-4HY2?hDqX;h%!z#K26a~<9?659Se^4g80;k*`hCjNMxR5T7BDiVmyP;GMq*H1 zu|SxQ^Ps3yo`mw2f8dUrYhU~)14917*5HlknUbz-jo~NsjO@Q~jTQkt14leaGA?I z_P9HdUdGxBCNkrZf-krlkG@tjx0bU2^MyTI0Ivi@sRt9 zhm?r^3(>w^M_M7!5j#f?y}mVKMG@a}D(kKc-m`ElBBLN#F_!NWIUfACu0%n0oUvR1 z`1qp5h1MDmNIbgVI)bnI6DRC~#KLqR=g&X*QVF_8e=&n>VPH3ZwHbq%w;Ikvkr_g6 z{4DKv{bj7efB2y~sV@}GJt71Iuv^Ww$yQz`n*T30-PaShRb0$*U-OU<`A9nxV;2Y@ zT~s1BfTTzKl&`8LzP3#63LD3v6Cm=i?8AZO?}sb}T#9Qz0&#bV^}K=C{zm}LO(K2) zni69Ne?rcAp~M&`!(dV(B9v#MLew$uDMPZs#S;;55`plRQxB;$VW3YVG?)h`R zmdH6BMgv5-V~mn4p*cDwS8Wjt?a_=YeJ>3*l8JjDa!CQ6q z9@T$LVv>KkcK(;To$`UC!Vd+tGjm+QZgffxe52kIf+Q6P>0bP8A2Il|JIRqz^uQOQ ze*z?GDz0sMSdQ_jpISJUt!brBXqw4@0|KS8gmHm7Bu^0q`2SUh6t4W~!(O?e@K zse`7vMA_gSniw8q6SLuvd3(ZctkibT6PC4E-udlNck+>Y%qtt9&*K^JtG^tk5uX?{! z6q5F$kReoq8J15%^cvnYo)6n%0=70=8Tiq%hm(xiLoVB6A(tL57=8k;Wct*Qv%4Oe zme<2H?DcS%c#5(nQmYc2fAtXIr1dbmwH`WcuOqN%4KpsjB&&7N;Zmk&lv+uID?`V+ zNSwT%E|68p?t{yOEh7Btp;hGhWWms&<>5DdA*PLo=y}q7rKi~!57Q#)Mqwcyl956p z>BmFf2IS%ED#%00NXWx`C5{gzOnW{WF`;)FKjuie@dHdj`e4gee|qs_&n?hM#CL@% zegd~pH?Jp@-`b;y<-LR$qG)L&-wqx2+o759-$>=lU$T+uick5WJi-If1-V`egMh9z z0vJqip0p8Gke?)SVRAX|2i-Sml@>=P-F{5gW|tPHv{-0Li~E>5h>NMve3a56%fBP! ztwTT8CoR#2_&?6zf9XROX)&lsi%%43;U?0;B+?RtiZ&2w3Cp>=)uVCX??#T$C3+{2 zGT5Qems?aF?|13^#s!@?tSa#)J@A1XlhkkMtdD@&8}4*}57Kwh>(-o{JS<>&w2W2| z^bxk=zCJG?4S=|#DHm?o*u5Ypv*FOA#E$_CpA}=TV1#L8f3$_ZL`1Jc6ZNg{erp&+ za1H$$wu^7^ZgSGfQm{)yQ_Snqno!@(l9h%7;ogfXZWcq&(09iTk#C{8TRfMfWmJBS z9^Mmk?g@X^1`W2Uo~*2R(+G=<13icJ71|j{PeA##GBqu9*&Z`OS~BCl>~-Apy-{Cj zhx*FlZ<_R1f6VGL`pS<=1^mp@;sY3JfM3x|48;!|OcmG?OHEr`{X|hQx0T_#g$ZKi zwIF6FrUS7sLsU&J8xdt}717WP7wtD9*5%C5GsPr1eJ)|Y(jCPCJJ1+A8vfzG~OEPwrP#b ztf}%p5K4jY1yID}Zij5#!;q%QqkCeQfTB^!c5GKUKYf8PMD)j%%(>2suWZ+tUDaD+ zGjr#tf8@gV^L@p(Z#b=D9hxTRkz%O-VAS7(;Ih{flB7F(u^U|F%0;=$;nM$8+y?>y$y>oWMeE`XD{oV;n zqv)v_(diwTjFL%K!wjHJn{A;~e-{dRL5Cshf1gr`GhFh*+nr{Dys$??a@8Bsu6eG) zQ|gnW)TceEFKSX>wxm8dNqs&asn2|o`bdY=Cj_ZaT|epz*Q36CM}1(9`n((USvBe- zJ)=HZjQaE!^(FJN0twua1gghC@&Xk6lXLoP*DkMFu83cR5|}>3U;2Q)^ttf!s(I=2 zfA&ZmC$&K-DKi8=cne>M0%Rph#r7`Ljy;PIvF1WBzyz&?bVE!qMGys9rr1shp!4IS zawCAyo@$iH?|UPj=mBneN|7Ipo_9bqSp!v79-&*w`Hg%ct_5dub7e6Y*U*x@-jiTj zy|YIvqt{i}_cA%`8y_WL6ViKpFW@`!f2e9`+aIow-m+!M_ku8>aOTw!N;WIL7ppbX zWe2`zG(&M7e@Mbl(7?Z&^8TU-#<-!y-m;zo7ZAvZy7|uH)q*^L3vbdgkHz9N^irP9*(T|Vi9m}e$-_YU6 z>LQ$ts8YZP{>FwErd!qrOpy1VAmF6pDaB7S`+L9<;N8y5Y*NXIe@Xr@yE{jdnb+O1 z%Wv%i<}x&++(#&H94m92SfUV0ivO&Dc3ksaEDBPz27PIWVBE9GI;l)-E1JGkr~+5sEY^(YU}P^gJTi`$K>XzryGr4r+94Y6JT>7Ok*faUh575G{r_WTK~a3 z*9dicFu$`AfRKOCf95&RVlrcxd4fTJK$Bs!UcZ2#nnqq1B(`s&jZ-rqfhDdJH^d59 z!)Lfk?Q##YZQ$k{%K8TrneddzZ8G4Vl=#!q?!Y@w!GqW(I$$DRA=utTKoPXvv0_neo`T!fpb>xkmHRi>d1d({n1GPmtNdO5*6u7di10F^r~mg~4tNL!A=bjNcroC3YgLC?sWe{q8PDD5#8+8wVaN5;W&KfiI4KV%At zsJ`xhE>0ETrJ6;a!E&#QXh(l&pKTn-^U7;jtWZL~U>dFs0IYBxda_sZeQP=8* ze<-Dx1x9G#A@0JDk#z~;Ru8m!{UV|IMb2uA=oi^iOxBO4c+#rO_9R%4V3U1(GKI7V zg0{5?>bT4cBqG>s(cv>u$H3Nvsh?@rv^WvL)ox%m$`^B|`Al z`<0#{2sSqwW`GE484R0EiW+F_5RM=if736#xas?Ac-?6K!7W}<^5k!zp@YTj8wIy6 zJO`pk&wHAGT=yvpf#wAYstw0A$J&cTXWH9~JI+dtU3;YUSjmV22T=%H?3@A7^ZYCgFC{b=hXdiNN**2tp+-MU^c9)Fvs3$0>uL9sz zenJXnGJ&<*qo=J#MClUN*e#~Ff1^YXvD=tAMI~kWdoJt9zM-Kde3qg%RVgsqXQV&@lL)qLepFEY z6~NXvb3&P$Z8Zh|-26n3oo(NKUbW=}p)e8~w9(bfS^Afso{pVqa3C~be=FMpO16cT z2@UICi@Q8hZ63%1u`=32&dd!b#-2Q|BD(mS_kwh%_8r?tXxQ@WRb6d{}x+JI6LZU<{Pi# z^LJb@RWnGww38WRq+OM@e{(EA;5TT?9_sebLw?Aepo)feEuB72x9 zL3n<69zi&#{y`Ch&R^ji)4*H1;uOHx_O+6zB8e$w&rTEU7H|Sdyl|;ix zCm4aUhU5QfT?YWrGc4=wHAOG@yA`fxVv-Dig@gl_`oDDtdQL#3;<;fit9|*!@p)Y8 zUow!B84U{HnGy3!f1Zl7RM+qf_Q>H4dI&ir>;4YrThX7xz`{Fy|AvI<&TMmRL7oEp zA?9b%@(T?M>U%{@u}jsDPa7X3<5yzPOYj+46YR0_@dJDKy_F|B|K0Aj-hA$ZdEV#NwK>~P5`cB4R{%!5*DETa z;i_?~$CHOUe*j85*MfsQYB>wGwH9HB1tsWVU7hCV07FmXYnGzTgz3rFQdJ-l@h+g> zT^mze>6-2K`vIiV3BFNqrL-sAs6c^8<)`6C2i$1*l?~G%z!|cfPdVjPHxG;Ah#nEd zf!%Ow;)|O%=xzJi>jUq9ckGXDhxhKFXJM*tN1|f$e|C4r1~IQeDv^drJ%!18KQwH( zoq4U^6Z?Lpb~uz1}9-2ClX1CD0C7)XIgjsfFFNlpWm?V z5J>8ASfVo{S>17QR>z@DX|Ad6@LnFkK0mrsq8v}^Jn~|?qXUW1;{HY!;~!giM0arc z6SF|Xe?U8b93k_$LvKv!wUbYs!)@ZCEds5=lRyUNj^eQ>Hlj81foR=ihATIr^>sm=c8R6#+Qm zF^2j4nI{IK*N1Ios3;?Pj_3OGT7XC%u^DT?e}+$kB9Gp{bUc-|%MJ3VKUcQ?2@CPk z^xjs-BX18dEJbcSinZ}b2>@zFsqqLZjYoGEQDw|{1dB2rjeC6rBgUfu3_cx)sdcY{ zgo%XV0j1CqKoyT9sCX3qy?g?)Y&bdkDdA^8`VB!mn&i!Miy|-2-4g}fHkunZJYqIH zfASG0FyfwvJpVo?uX#Tw_2Yt-x5U?ogGccf%+B8t1jGfLf9x~=*sx45^*E3dGiO=U zf8%c2tv@CnvMY&i&SmDrY5o|;{4vDthD-_BgJe(p6R^vn?}w&YQiP|I7vbJ6Rn?0l zEx2U4eB(QNH^6Hbv-0olk1$ z=SIyhQirt+vCDkEdVY&cUA3Vk+v2)x&PH)yO2#O{;lVrirfH0?Wo2=4>PmAS5M?2k zFdNZ(+RyB{eb5D;2b4tR@Z|Cc(z#SQo!jcN|JDeinNDRg%f}z0EyR(nt>4A2e~cjE zkAm-54nZWwSJI75OQS3nhlvY+j9wl(y!64;2An@}?La`ArSoS&!BQdtJH#HHn~5nf z=$k(0kjFCB9ALWh7pQ}}2*tcsuZ--V4>ZPtPWY|XHg$BJ1VHm>5}72kw9w${bF*^9 z_og9Sy-e&gea8QdI7g*hR!;a0e=w$TDGm8KwTd$bgJEzit^G=3k{il~1{ zaZM|7H;JeqEM3;&Vz<%e3m=(GV=$adNpS?Vsr5LoH>3Orx+Q29M!> z^){_NS6@v9u(ncZk&P&3Xlss6$5)DS_u^p2ecD zy+u=C1B-fOEzZ}o!YWD@_y7lffiGa>-&5#m(B@7=k9}TyN92~QV3_PhDVw=bxN16b zp)kylEMt^++pPXu0P+ldDig>LkS3xHO+=JI2}7qR(3baH)=O8`=2YWAV{6{xhEEHvd%36n3u=h68;asl?mdu_?^?hk9D~l zfVXS_mP58j128=#MslctV)7J#H@^Fx09;mwU|mu?&l5=l@BmmYWqYDx=c|!7mJC8C z0FFVok(~Tef(romf8iMu0CwKz{{Y~`1(7rd0Jw@LkZ)FU^l*HE`hVxHSDAXe64FKf zUxl+Bv70DRMs3&r;r||8IMI0>xZnNnfVsMroBD74uj;>QulM<1t~&p#K_AR1^1trK z|5|FE$Or$6M}lq>?EQE8M@!bDwf)yluxU0~UX}e9ci4DOfAIC+5xV}Xw*g=M*ZylT zIQ`e662H=aaY={f^WWC{XlwqfK$oKfDgPZ${wtAyJn~;#*_bG-WYG;moX%zBL5w+s z|JFoJs`%6QkMG~f>kRvb#QS&O2@~4=8_%wC5bj?qzx_)D(7Ns4JgA-QFTHfw#QqJN z>b(7Wv%%|Me_O7{W|19{fl|}7e;kHJ0j^{9FYEn1~v-D= z41j+#1)GU~YZCvGd?6^`Ab3}R_`N~wpV9QKHz>inf6^P&&Oxq_0uhNo0bc%Ce0@HWbzE9#W|HY*);~qlC zpeX|(5vZOcds2ZhsPH!sL@(u{yTnt3zde~Cl;W1lWB2LY(qZo_%oT_Gbp-q+cbi+q ztU$;aezX;#qHP1hv|3-)|t20Mhq6>cm$Q+xy*T?^m|D(^|T)OV_;| z(rDdcGQ8gaBE(?E9of5ID@Z%vXSJsA@|Yw#Ji1=4OB#fqO9mxJ3F~Yo3DJ={ ziS5-O|I7aRC`A)sz>81v{5)I38dGbm7%@Rw`KghB^(gTwk5;4je@DQ=uK$^NrtH5V z&PI+CGc}Il0>b$+WpNtV|Mta;#kZ_kW(ELN@C$5$9~ef1vOtkhphH9*1w?-zjtI57?4Xo!gRnO;=!l-&yy0D?;bA7_` zRJ!|qn2Ac44b$ND+&vzZmDOA^41v+(NmsO!uC{d2jpU@8mRR|yCf%Br2L)M!BgOSI zbZ3zQZgUyg_&_3no7Etc$hUL>e;abVAhb8H1l<6+5itN=OMcF)s54`~!dbEf#|>Oq zMso*N-y`I}5QM>HMUXAFgqy~=C)!+BcEm9epOBjQLN1{+rkKG{6DSS~E)ejnSN^3z zjuWP?Ql@B4eU1qSMfk3S-0?XB*Oyq=~@r6{*xS+}tTw;Ps@s8-aiS z?a6HD>9EzE|CMe3_;BQte__^-d`MJ{FigNuA{EvslupFZ`HFR*>;Tt;*fSmL!zcpv ziyr0cFkMQ4qmy-J4mxJY`$CMkA!CqmT^}{Utrky_>yX-L z@t_DT9`#u~^k!`qPgiHTi%EV|^19$9crtkDBFZqXKxHqt6rOy*oG!9tPeLF5fawAW zG(~0r*>uE1)DI7q^Cepa0A^YCHyBh5PsHJSO&|aI#1+ZIe?#`zD{I6~COeSu?;V~7 zhpJ)~pa?j#fpoO3g{Qz$hYnA7XG55XUU(95CWq%rc4zQ;`k)8=D?HKE)8-8ip>zjP zal;b}ZL(N%R)~fOpmLqlSds#%wTSUCYBa3Y_%93GGC+b`I(xZ;mBB6TpvvrvQ_#&4 zu_TOAeg1p5e>$cn`_fc4*Hi+5#~MSEGF0@o-~o^Lg1zYzDE60m0_CQG8|9ENp-x#1 zubf(yVgMe75;A#o$vc~rHlzEsZ4t}3EWR+W+9&><){z}ho$7D9)_w1g4eZ`WK;4D> zoqRzUf9L&#ZFeb56cKQNxRSdgpa)e++YYVXx-Ei6=5WH*a76W+C zcbUaEJLRaoAZ?zNXA>NNd!lEphxkh?IwGw2q5s#Au+Wox8&&d$QbkKS25h)~-@ zw3jT=f5JPO>mTTy?z;i@J`gYZ_zy97#LA_PlflE8$-7fSZ^@Q-;{Omp$TVL8h|1d>nWRZ-eQX>S>BF_}>REz!-?_8VIL7$lwP^@x7JptaSjtp6UvBe;iUOoUG zIC^jv90%oDDA&7#k!uYMk((2Qt^!j^gwY>ke~w5DJmLxln{d11a3Ue<0we?Yu#oSM z_i9)$deg-*f(^%?18QeNMDh2v<28onjgtHca8zk>Y*k31(bx#8JwZmF`L9zl5`pQm zy1Wh+%+i9gc-o)i28-w1?V4f0X_O?>8>D-(8*Oe&d?4v8?RNl);GBG-v)WDU&>ei` zf0|rW!EL>ZPw+-ZotPG%!kexnC4~J;#4rl*tDlr7jllLMUyOpH<_;r#Rl%0t$;3;M z3^viSMOK#koEwx_DvHV-2xE%oS+uu`%$uCtd0@}>0Rt#2K+~Z+_`*73aA)Qb=xfA$%7l!|g3QbNhNa}cImAY|VfRAUqnFc;Wi zF)s#$X&Z3#DU!uyhHPCF>!BDfu?0K%xe^=U4 za=DLmgLlDC&+lv?(B`#q1<#BTk~`{66S&hGJB^k&7Ct zGSer-5BI}IE0gel@7@(ga)qJs3!&S&YUK5~oqhB;=;Lj1Tv%XB?~Om7_W28dhaCOceBne#V)0Q>XI!$U0TM{Ts1fBKME0?Cq{ zXa+i}_80Be9f9`L-q}$=I7txHo>CS@rfYE(?w`H^MZVMVXXhvlw2`}&0>Qv-F8F9# zi7pyUs~kzi2nq}?FAP_(5-YEiylU7tYBok$ryIo%?b3f%W|HIQi3#?m>>{=z(K$kA z-l(rb)pT`Yf#L0B`iH^)c`Xuu2I zz*p@Wd&%ID=xh~)OR1^2YR94Mc;?Y&7&`H=7zc#o*`a0*2yPj5e^xyLgTnL6GecZ# zEaX6}O7f8rH^qRoASVJhuVf%D$oXZzs1>&_6WOt$VdFOJ-Lg0+j+Lx$pb%x_eA60b z%=ckmNVmrbL8~TsVKDc@ds*%JwWD!r#0jxF$8Ob9=0EvX3EOowdj?oh+z@YA= zIXbz@pl>Qido*WzPqJ?0K4D(1*!HIYo#30g&F|be{Tj?l-X}=ZZ%Q5enatc z!DTT+mdDpcM&%O}K7Wn1%METDv~L8W|6A-dD%S?b+>NnqiF=X!J6YA6HyFO&hq8Tk zpKC}kwaIgcxh7Aos3;U4NQ=jc;=$sDT`NV#mjx#T6JVxR`+@HMSDAdpgETAPLSnGM z&ce;x=2!hbf3E!Gs7grN>_ClH=kZ3z<4yHD-k5g0A?tX<>3EaR@n)CfjUUGwbvWMq zH{RrKyqVj04cyrNsJ@LEgstL@f1Wl;l$Qn{y_Z_=#G8h~lODjN z{n&B}M32}-bw*ay6y9^GDGT+mqGa|f!$*5DEDxYb-X1d#wP6&-%#gYX7AlsBJr16~ z#8lN;#f}H$3cHJ=fM6N^9NDjeb74d>0#uQVLse+m#ewwzPdZJb*PrhGXq?u-s-1qu z32KOpf4d_WFo4NW%aMw#sMC#c?v*i4Exn*|g%1`yoMq!dAzc@zhwa}3;Y@^pIdm4z zqmqWE+(;=PXkx{Q>s6evxxk9a;3I9TqM09t!CB(j_G}9%&PmRM&z8lG`IL`}Q{n+T z1YaPM$uI_-9%O;Q(g;JFe-`>BX%%S^u> z7XrHiV07BP7vwUrlV_j9 ze%&9!Uq_QH`FHf@hFI%v`1&dxVb+gK zjPt~oGVcKgfWUwtfIuJ!fCwNE2n2!v zI|4xffgk`OkYEr%5P%{ONDd>z#kB*Rx^`Ex^Fb~i`k~eEL#w-?{fG;@Fr!%hH9eOG zbqPHCU0{P#CZ~8gHf~YplHZ+_EYhMf;-*$Xs6?VMf11V-bQo@&AC4?|P?MR#l@5cw z2Vhh?x+m-@DRd1f!mpsQ8o+k6Aca6e-X1oR@*i zIX|mEe?lrOD}{ybvx#WwL$xdUh9_6H*fi;~KkwiL^BDahLnJTCg z-IefoF>(n4S=U*K${(39hAFyzH+lv})L24MUNB8D4$=sFu66{JPT^Iy;;i7aZ5-N< z%#nqYI{tsT^>80GtXb&JmhdXBZv0+K_33$ee+#CwwJbks-Qyf|wr;-)N49StPIKXG z$-&urkj^V4x;*5uk8#kQ+6!&yHrzu^{g~ZUc8O#8bte#NDo|cUsi639dp8bqgvNBC z{h&W=>5=~J9&3dC9;;Cxmc-$5L+rtVFE!Ib(S7v1c6=z1wVz`CFk4c{)I(dfH22)4 ze?jv)qgJh>8SwWk5=j!4L;)WPbWb%o4c`JdQu1VegEW^w&Oi*n8>t$ubeswFC$j%~ zDf=Rf(6!o(sB5*Mv7Og-t&YYs{J=`9@aLXwN9xyARf;}$z^yJt9dKEXYD;p*)e%A^ zC!Vo-Gf5fk1EQ|3o;{s;7RVUz>ME!_e>12S)H9t>Md%FFF&*=?JvOG_Z$M{DKr8n- zi(6Gymh)iCY(Vc`fo1j|%~Td;l?*Ew5Drs(r}Ox?55&0f0GrM5hDxZc^PsXyu)sCO z0xCmMG=UAm(pK+Qy7zlUTK z|9?SQGn}SAWW^_Kkm|7FK=!BxlqGwBtr<*XYyTAQt@zgn13;Zxq|h|H&P+ou(Hpe? zCJk>WisH9`8uRUpE~yrt0jFGIx#DRf}sK?wkPMYBy+z3N*H zxJ9{(&DrR!(EfU{v^FZ7BL5T>XG9RDe198>2~^Hzojgqpmg-_qzaQe~5hVRuX*LoBEiq|5hKt{n(xD+82~Ol3fDL|^y#~5l^I7dyFKJjso&*&`!y;dyIxM~1D|7i-uXI6} z1F-`nK=?musSHb4%CImE0@oJ&lu)UTVHt4@t7vJsZy^;UiedTTQ4ID}c!38C8HQz) z)G^RtSWIty0k{y}A68g7={^fG0(3BkCVv+pbb&HK z$O1q9$wxS#W&m*0#-Saqk~aQ+8|fLAd*6(2XkRvLAAf3mh`mFZVL^(&@>7(U9U8rY zWjRYVkvAk0pls8#?gqfA>lao>U6ErMo$w#~YO7ewR(3}LTV8tMa{FA4Jo;!A3TL*s zI`>cVN$xMvi<&${HN z!4E5Dm!~eny(?eEnlj`633=@CF&<;Iu_NSK% zCZ728E~I}g)gT46=^&qt`uWJ+f`YSZRy)NFBkj4w<$&B%d5!b;ov}9bl zWOzW?##Z#-6~Ba`Lw~#o{IasvS)ywTV|sJ z?FNV=IRgb_rd0!*Up(c~Dir-Lauz!eKiP`INUJoFR{cSY#DC@6In5>87#mP3eBdp_ z7dq6<*T7gMlH(k-RKco?g=&7eFzlYURdHF6DRKgwEo)7fb=QSDBi7EG!;t5cmXaKY zW25x*te9gbZdJaUsTvk{M)9=_vh^U3DO6Y1UsIdq3!u|}^RDJ4P|?RdfP2|>Jmu?_ zs48c6BAvz^WPhhj&s-rUWY;(*$|3@BdsIC6XGXpf0%Y~}AE|H6oZFTvwrr{D#z2Ac zHzmzpKe4!JvmDslkP}#2<;+$$IXv+?Ty2Se10N1??G2DCdV(5d?ZG`OHsy7r%4y)`fmqUigV|`%6NsUgUG7zjqUc!+*3>14y9HaGhv*Z`~r@gG~XR_;4E! zzHtQfr86$Rw%q@lN0U2S`M8#`QNca(yVa;|(^{k6gSEXFbeQ4@lI|a@3B7+V6vXCvy*h@H@Ex0LovourN1WFN2y||(T@&{u^h1!((eQo9mG;i!$bez z_p?`+PSe|6r)lxgM!nJwP8oLa;zwL}Wq*aI6q&-v`f}4jg)DQSzle$<4RqtK&b&so zdDhwBvY5T+^n}PkEH)&m5=ok_48&iZf>N^;uqYf6Ui*Xqg+XSIGu6bi3^{I~;o8oq z86@DoaY+gVParF^AcI{<@JH2ix+qV)7iCUo+KAMdHiFi@xbI?2u(`ZFgbbnt!heER zj}SYJ#C2-4c`7&Btks#VKNi@KmD=F2N0rp8h<23EZ^cF-j3?=W8hY{=NQ$nbQdA;grx|*)j@oRs#ji`N!Rn}oPjIYN zrH+z*>(kDR`NuXL)o41ZBG&3YB7YrqxddqMvz-7*eH7G81^78iXnl^tHA9O%N9|gY zL7dj0OLprw8*Wa2`2aZPC|Ar;r9VILh8iS^T0Jo5ry{0Rjv}RUR7=qXxE3U765kLK zM|Bd69u2bGB7s6MpWuC^6$<$wVx~_QO_Wvfv-(FJHZrRKIHQQZmXtplUw=2FbQDGU zp$Gy~56t9dCL)kktAlAltx&*~&$XdaXFh{T(~3ZxTaCXBEhiXw~N25k+r5CZs= z&nTUI44?t#4e$qaf~FqKUGj&q_jjDslQW^oeV3kfbGgeO0%k0t?d2x4RDt-|0E0de zjlt5(ikoR?t669#Odw5Z_dmr$U`sLb6)vymnS3~raOh0A~*P`Lt>-{hs(X$=7z z*%+BN6goo6_3MGGHj>;C4FTVfJ4D8?uuLfi@GkXp<4%IW{27-Ir>)}Ji=P3 z1;Pkj`9wxU-&-Vry=y@qn1l=;g*U(`RI1f#0B3BLeJ*8OazED z)nxQLuzxRXu}1oS&;qRI@oMby_%N;cFh$WNs>>-!bO9~K8P#cnKWsCBUpeI38ei&{ zbY=&g0q{EL3H<5Wl+@+vw#9L&3pc5=sLh4IDZiidZyp*PwC4`|H)ysW0AOy=cby;p zQRZ&hpn0V#q1xfT&deYPpbzFrng-o0=TxRSFMl%oo=A!oa|w;ROu(8uRnZ%?=yz0^hvZib@X7 z*iMH#?x)iC?x1_cHu*kaH^x8MA-9}YbAYW(`$srhqM#Z6J~1niY^(kv@m;fiNJ#A= z?0+9p{fOyKx}Isb*}dNJYWg0`tE&uLAn3q}+G&i> z4thX|+1I1IX^sx`@t2pjbsXAmpgTmGD{oXHEaOf;wZ=FXg^jTp%Rq~aJIO%bTa6nq z&`(Dcd4aw~@#aY~Fv~$&K~D|V73cthUVm1gi9etrJ9wrep%j)sG`)45n-kOx`Na@v zumqak<>vs94vu|3%N#Atfx!wP>)!X}5`Z^M8!CB|hM-ZENN_pwTKFUkx$L_ETiItM z&L^f3tAhuRVO}1UhgH`A=#GZ#BIFI+6D&gPzR=0ydBNh@(3PxszDmmw65?>MhJR-+ zF|@XkqY4$l#pc+K=lt&dJInk#dwIY^`)?HX{GCPDHJes6!u z=^LvdaM|AMaHX6A4M&H@XvAf#GOh%GyAccgvNuqC?aRns z6p?gyFtjBi*vi`h38Qbnu3@2|6N?C_y2V5P{HSU!Dt_LKrAChkwZOLkP{F zSl!k^vjPwVfJO2UX@Auos|vlX zvZo|9;_=E$Y2g~u3|&JO-U!+LK-Irq=Q=%-iIziv>uHT}J$nu>ehXtV8=CjG&38tJ zCXVpP#(J40F0(+>t}C7-y)S3PU!#>6$j8@ z_%|6bNMU0z1Kxrz5zS)^);a|IzsoZ6&Xoq8*${#|N~FaeC6=Gk-RU`Mopy4XwCMI~ zabtT5($SQ zf-rw0AV44(7zhLcfk3bj36g4(*P?JNrSyIM8!M*nvZte*rrJbz@2<%t1+6S(zbD2!d5|H{G`%K-m<1|0>} zQT86UqR2b#hClqgtzhB=Jb18%z>C|nN?4;-Za-q?0&=OPgPU{3TJv=)RY=b5!R0o7 ztiUM{sMNhfcvRUlYZ)|aw1%#vSql+3M-_yOxqbQ~@T{@on(d=F48{+wlEgjZQGd}I zhJmlQtQI4!JwHf+N^9)!XfJ&+t%;KNqFw7ZaueP|_S4(Mo3J*A-JYPNVT7y^< zmwj>6nuXNbBB?dc_&c%GT1ay)@Ac!UwWa&R`J{Ta184IotygQ57FC)Lg|#&VmH9Z~ zI!L4SMOn6jX-z+L;MiLDjYvSUHGdJnm_IJnjV^y@Xu`8K;|T-RWbwI|XK+grR=(Ur zTOp2|LFW%6%zUqJqTpzZ+uXIQgIimHNxyk8R)W9|PJ!CrQkb?JBgP4JY5ZS&{9Gb% z{2beyv5ZU~!kU;_9mCcI0Fo+Ji`*&B3^=Bb{=!qcLhH<5%(%|aAG{qXi+{^QZtW3v z$mP_K1&NS;esBVgHrj2=7cfJ3dK9U)gp-U5BuV575FNO+JJ7lYraUn|;^MClYCGiG zp@iGjEn)1q|BE~Mhur9of+avM?QnlU`H4p3>A1TMG^TaaI%jWd+%)*;)u*S^<~15V zlq(3RCTSJDwbF(-5)ZnVZGQk+!V%`lHjv@am*wKPK(ulQ%F3u-H1E|=!_iIFYUcLw zy{BDOiwCWkcEW;kv2$46CX0h<>$%=u)P;?qqaows>VJ$s@)C&#ni;|5 zP-%3>oPkQ2uo7^i5e+^sg9+a%fM&N2Kd-cYq9wP<4DHZ7XVXl?$3%hj$O_q($KA-3 ze*Lwumotf?5X}#2bFe{K*AB&Bi&QF38z#COO-mdTq=jwR^tuxFzT;t)VutR}-BJKl zJlr@YTt8{jg>kL^Rew6uBwv;x0#=$P<5_i@=mKq^CgFO5nmmDynt=0-rY85IX5a6~ zj{Kc4HZ)~$Hc$4wYp3iNDX^6deEV(YiRjR!xQGs2^=V4@Zw?%pN%>5shlbCga)$?- z=C-_@S>Ph3O5zO5d{t8#O%OY!Eu!E<*+O2X%@xpy^KqFL*ne?#OQtq((=sh7vYlll z`HyAVj;m$B%3VFja+TImcVjcWC9QYOg||wcAB(cOTe{=1vyjZ94lF>nw|o$JW#w)c z@~rsfdKl5)#K{zPu7UdYi?*@&bDZ%B~ zzIAKWPB@v%WQIS-ZujLA$!N3zkpTGHu-gDw>Pu2bxP13&*VJHuIt>?=y4?b?(0ECm z)%s7(Q_DqD?*ncvJY$G}t!0k0=F(Y)R~ci)2pC}rAAh_w&=OV%%P?t2RS*^H%?uG0 z6u#jple(#>ye59P;`no^dKYvc6UW8eEnb4&9vV^uhE zTOO&G@K3K9<>f9Su`kNPK zP+>9q>fcwMm0-Fr8vT0AUpD&lunS2gKH==h**{^N#V8UtL1=-Zh=t?Rp(<;aWl3RHx8R2D|)8a(F3dV*sh5A&LXAs6%KbS zz*k7z6@Sg$Q4VwpY4|?}>LUBjfxI)r#_zOAyfawcc$vaGBbpcqI)#vmR+>e14pAG1 z$A5!xKp5P;(=Jk}sALOd-MWE)QGgzAA*ZKzO&Q5+D%_u#vjX>x5#3Wg(W7Rzz;j*BEoM0003Zf-}}{(n*Sp!6A6=Ax5riV$3Xbkj&xZU*OAI@*ie z4Dhnb<7QN6HytB-ho@wCmm}Pa@Ze?*5raTCBQW>_w+0S)=1=|6D1OmAV?Q3D6}Q<^ zq|hq$X`*V`TjayMw%IWlz}^9)ZAQ|Ec4#>T0o&iM&GJ3ySObIBX4$OG)Jf{Z+J8)e zwVBEROsh^^bsn=SDJrKYJQmiPjfytQS+&M40xOSHErmYfY*w<5QG~AEqj@u%sSfA5 zhuVl=M8)s28BkQ(P_}z;T_b=H!)nQ9erYq%a-}qxPckLY^a*kqitNY?_s=Pp0u(n& zv~ypV=3eYMR8sIsq6H1Y-W6%srhlQALrfN0z61>$kQv6av_^7xXbCB*b62RC2pF&@ z@X`1o!s;Ka3X7?P9m5QUN)C0mAdRRQ`%55L-pV9v_VI$+Pn}n^F!_}f;1ma(5RqDm zLloo$WrW*Wz@nnjhubr2&>4Qqv^=xS-wI(K@yyB(UW$Te25t?Kj$BDQvwxT_zJg=n zg<}DAW`_{0L$?c>tWSAqeJ|>znK6Ro8lgn9yhk+{V;Y9Hf?Tcf#Bv9MIoyiXS7PQ7 zNNu>4CQ7*#xWQoR3awxz{()9tm^r2!&k9`7Y+V`j8ynV&ZNzyGE8tFPsp~4h{Y>u5 zRR9oJd0Yh>;5~rHSFm}>FMpH%%e&M``_1bkwJf&2Ap|VN>aA;A3P8W9MP{=U5G$wU zKQ{M$K)yy2{7nsDXITS*7rtjW%WSAEE4qAeh(`kO?cKC{ExXS?jDJs{Yx1~jHBpF1 zL`lPL%6s2O_;86YGcH&s*!g5JZaFmJq7*v{amxYhpVg&Hbb`@b=x2rY^^X8hhZooZ zlpz-$kT`*VI7E9T?|<(poZuABAn#ac#x@nBl(XuaavjNSQ*%l|LiCC}bowAUIjMij+gFh&dYSM}I1_V+)^6FpnmquMV&p z)1|PgtdP*>V@e?i9@O^+oyy_UWNCxYcOG9v7H*z*0C`FTg30}I#m>fl4L@q_uR|N| zBA{v)ffXLLbrD!z$j8h1nCzO*M(+^%B@Kr1+Fy@ArmtYXo_D(JDk7*T{C(bpTn~cN z@2;HVGTNp|(0{{npK0WhMFn`qN!;asb9rU)J)c}VD+@2ZrW996*+}4HoWgNx2pVIG z*i~|630E{iAYQd>k{OkLKUy@0$u${@SP_%0Ik(2paVLQpdx`w%V8 z;>b6mAgZu62m^aR9GJM{Ary4^ugw+FaKMF;;lSz`n|}sy;Q)elc3I(oBYizf=18={ z{$&T9CKeM#-4@>5OLkFkI4G?8AI1z#GD;sc*|q|t^mbicFvFoGyI27p+4zV@RtIZZ zYeAdu%o5$({Y^C#>62tvH<1l<$4^6k|PG8IQnF;#l$88 z=h7Z6PJcy2Nu{P+N}cDx11&8wxfBQ^gF(g}ot6SxED>!2<5-)CqqCB{ZlJt%vMqon zKYhnLR%WtnY2vZZF268?o$`sc11TJyd%Puwt;4{OypXMOAW%wZ0b%I=mD4US7oY2z zKBcO&(v^1#U~UBy74-fzM`U3zaT1J@To~FiE`OB)2SCSUvOK4>IO-I^3y|ofZMf~c zZ{73f^Cuw16%^Ex4jucFa;zUA<<)A{^fpR==ow8Y6JQC?q}?8`R>c>=seg^XX69U$ z+GI1Lkzq?NnbO15Mw!r~w7NW8;Le>tbw3{;ZWcvb@e>Xo{9x~}Nfmzrf@>HM0rGWG=a`dlN8Go}$iDb>kx1_<~XE;i(LoK|2p4Gl4y8kB+%GyH`hM{@%U(lwl?qgN_b_AdNP^pboctL4UN} zYqPYNO`NG&fUNS(V4z(05|yd-I-^&rd!c0p+G16j0_sRs61>ugc z91Y2o%ztXTt5I<;HHv#_%`qHK=iU$-({+1+s@u!T>Gl#7-Cpjwy&^r0jh{w_phn(M zBh%K(@u7iD_AY*VxwY*@^v>vXmw%%#?f4hU+e)G|svun~<{!QoiI?~SRI=CZ0XaOz zAW2dV*$eYceg-MT`cq$pB}T0pFsf?W>So7TAL(yxQaUB!Y3sifu={6CBpmXEQ)M97 zNH2B{i76t<5q-wr@dGHTYk!aayEKVj zHw^cA)rejzF)lxWDWkO(&Csh{TcZ*tJH|pU{6AKfQt0&~r8~cWx40^7BRqkiR{~k_ z20DP9-fnxkw)0>F`n% zdASRrkb1(Rrap`4lomNvTYp5)YKUEqArbrG_-ijj{L*S`;XUU6@N)yyJ&uyP?;G4= zOipy|g7#=TvN}#u`Wsi5GpR?60|nmm!u8SE-eW3So&tlDS-2IupgE8Pr`YAYig&Dz z*ad@`!zYg(gJUiO{SFtdcp*4!&=TN4`dk;(pCvhhxvq@=m6VUjb$=y+T$e0a4##yp zW-K#^|BCaN)@M1vu%p=+I>FfyJqJq}t)$>}D>Tpq(78stXrT3*E@}l(G*C!NEqcWd z+QR&kY_ZabO>W-$%}n@&j}Gq!h_$M62s7wL*D%Tm4Wd7bO1KA$Pr?!XcnrFzS`%rgT?s+WA}(*2O?JAV9(yGHf6=d8}6`paARkD~f) zqIznrR62fQO;fp7P$3xBp$LhsHowUvLr1LIa}yuso=_N%{5XbXnuzUHUZy#B{dH%)W5)J$e)7$< zDKT`NX)3AT?thuqi|-gf@%@-Kywi-U*R-|ND?`mX4Qp3oF;fH z5`BSsA-e;?Kfu#auP_Uq@4)8IZ<l1av+CdzzJZS@!W!~Ay&1bFohD!}jP-`D0yP0wLl zPk*$Y0wSqVkQ==!zs+!xnk6!9u$4#;X=$J4zT@l=?{zLnV6Ggs`;PMp3r*T+Qs=|f zpJjcMT51VB&2Fi&kRbQqias-_SkoWw4>2_zL*xFg8FWvkR-B!?GBr)h)Ciip-)~Zz zemzQ4Q$4-^5<6qLRIVTBG|}i1yrfJV%73gC0(n!rO=5wyA^g#UoHqA&&-)-(NIS== zNkn+csZ~mL-Zhc8!Cn*(x48MtJ2jA?r+wUqtYXeFF;n3--Vt8$JonVDsIq6g8l(si zC;-YBLA9Hp8Ul}K%2qVvTyfFYzU2tjP9;>E9J-l;8v!&R07z6+R8&+{R8&%dA%Cdx z8xi~nfkH4p68QfFffWS)73dG4nD$H3wBPNf{c1YxH|l9W98mk2hT0#^sQp4o?T155 z?bmy1e0|q7(OXskk4e z#r#^ZjWANS)Daz9Lw`>~MR&$8rxR!;6$iE_UdmHS<;+>fT^ zex5G(BLj0k&Y1hz%-kO+&42x1Z0@IdbHA6I`(^6fKZfUin?3i-@^e2+p!>53-H+wa z{cVcw7ix5W9Hje=Cf%QA>3*L~_uJ!iKh&rD(?s3xQ|kUSRQLO=y1$pJ`(jultDsyWeKm{a(iImq~WN8?*a`p54zT?S7zY_cOzGzkklQ`?$Z14pM3YH@w?yW-~DL<@AoNqe;va6l@{Lb2 zgx}9p{C<7N@0VJB|D5ytk)q!pNBw@I>-W!TzaLWf``5tVA87pj-^|}nl>YuU_V)|D zzrRiX{X+HcPs4w|&;I+*{NK+L0Dox&@b@`@f0+XObsFGb1_6Ja3HZyifWK1){2>_! z{B=Iy&rbyYQ-3A!cN8e_pIU*xV#WpjKr!$aqk+H84gAS;;E&S-|8GF>CmMpkm=XMK zlHl*f1b;G5@E4PUzfBeV!LZ;@vju;kUhr26gTEaa{FTn&Z>I)-r8fApgM+`)9Q^I< z;IEVie{6j42l|7*d=rHKLLvOE6_K6Z#2_HT`vDR_AP@)yf*=G40YiWw1PK9`Kp|)d6aoaH5rjpcgiK2v{jfK$ zV=;d7wo9VexnS`&+s@k$!}hyQ(L~gQudB^K7>g&d zgc3M(*?;E2f+2@Pza<3F;j0EMm}^*5PnCLdXZF`tsFKSq5hAvN%_xQrQ#9-^$j z|0LbN!;wYR6l5pzVq(4=Ip-fTXVBly zT%8pu8W!`T;K+DE90+sKm$WR_Z#~n@^Q}9&7W4DdsB+B`)F=)pbMJ#L0wFcpl3HqN zRQxfX8l3>Qp=uO|?z3tX$0@jMHM%gg^nbb<#TX()F6iw5*MvYZUk(ov7LQ`7A;>5n zh+%NAQTi)R^F7+QAKM+5ONI(&A+|dID#n90y4I#TzBnyUEvhz3r#6LexC#j}$S5<# z*l1auXLD|7)BnbHS(n3&sx{&@Zj=cU6=KNK;|n?;eUv9f@@d;9i_lv zLYUdatEx_(jQ7=zQfRL#Vj^8_OH_S1f&lHSRT)Y0(p_z|D!@EgvTVF4JZ=V6q&X(! z3Wzb^n8#^112v2;)HF-?F*XG_arXhBxJY{|Uf`51ue|N%l%L{?4)=7HDE=b-M@Wv2 z|6mwlq#rJv)pqDQL4Wpv1AcNhYHm}3*XY8Zk(NOUm}aF$TK8k@)<{2&>|Z1Gew?OS zRRD7!X+O#^oc8R+Mt-*uxy7xIDyMqp9|AZ?3c@3w zd-sni5R$eR`AJARV}oXBx|6>Fb4WUEfITFrk3;X;5=Byf*MGIRX<~`N+L6>}lS_)i z^r6Ksl9brqhn}j{|0YS*&+n?3O49O3h-*ozh}|$rtEV3`Ne$PI+9X9Z-PZ)dhCZI8 z?LIbrl1Ak%3k)UYG`p%%();sbq@=yHk1r)1|9iqMp=`iQMhGsdk`g+==_;u=;TF?M z%8bm3>q;sK2!9wX>AyE)Nxv2zDUbHmXt2t#D<9|iB^?a&5~$5!-4{$6AcG5Nmql9-#H3%uq^D*x5t{R~^v0y(h}q({ zfcDCO$)qVXR4kK9DM4f=UD=?*nG|ar<1;Cy_UI7Nq<>Ao#f;LV{Gb<>P*0}?6okW4 z_h;x0nQx5g8>D@BqD%nhX9;{-Z2e+3>AWZ@G90`L$f~&>SgQn~aFhP9=>mK7=-+^( z&8*V@l}lYmgDsbgML=YnR5|iF74B-p-5F8=nN+jY?wmC6@mk1y*c3kgr5we!3x?2qB{q&!sC-#n@1Xj7lk zWXqtF1RX=Q54=O(g@1g~(krSo+nP`ML%{m{13#%x`H|unI0%>?kWTeY>OG$PPx_%C zgAxKtrJsDMMhKKza5K)o1xkUBRYc{7As*S@M}MZ-*nc)&9h6o?ADnMOso%mPW*E=V zx%H3y->s87DFNNrPzu#lW1b@fp1z0D`BD8JN@r+$&Xrr7mu7Jx=w8!odWlj%)te}l zK{oeu6s0(T0cSPcczEi!hbq%2*rGHIX}sbXD9u;djDL5dbbk3cqGh1F*gXg3PWpx( zw0{Lj1a3E1NNGV>cAB2AAxKz{41`2ZN>ezO2kR5casqWP!6-#8(C}ws62a4sXBQoremH`A1F2XlsW+V04inU`z5Hf%Dqk%awEw) z^vU~H^dv`zRBC;wMU+Y-1D5(ysq0UfXn!j8`3m>6Q)xb{E~Yom^wF0Oov7!hRHb~v z;?1hG;v_0wl{O0@e;d9@nkH+N>iAmvCxEo8w9b~|^Hu6b7g)c7Xb{9oFYflJ0$J&G zrzL5OM9WGet4tm)g_m$%RPD)HDH=ivWG))M6A!o2?>fzErAUi7yWN^y6fTrAp?^7~ z@|Png&XzryS{Di)BTA1?N-LzcJlkL%8wvetUj0fxe2U&*=?cjuJzy#3YL5pLa3qri z<0lYn0u+{FCi0GIg-bRswBaVH1%NBt8B+qX0LA!9j1<91-u5I*?|7e<*_6vtS6PZx z*7V!LYvk92GfSsJQB%y6+nAb?(tlZsnyD^@`K(-ORTBQgJ zzzYXHCt4xlTME<=mJ>uGqu=IN^J1>$esd!+6v`+r2_$nc8A z%Y84GKB_wEJQcmdyScP55C(VH@m%_@=hEk8N2vY*hLvfn{SmHk4kmHmqkEc?yc zEc-8zE&F?K<+8sodD$QhNheSw;P=OJ(j|3dHVC+G6)e~{|gU$61m|AF?|uN?cc z-_QiK|0)x--`gCte=$>Nf1}gT{tbj^zt1(%enKXT_V442_QN@j_Aieg?Qh6L+P};t z?f3go+D}Nc(*6zS(tmyuFC8zS6-;6PKp>L5Cjqg3>3<<7)CJ^ z#vttn5D9g)u@^DDL z(!~yI)r9i(iKB!D1P?3JjPjr1IsBnu%Vr4`ZaG(JFH(QU-G3?r^d&TdxdunuZF{bn zcB>w&IKv*-@uUx%TJnvk&Xgm0B=P1`0rG?@u^|?ryT_~E6Ut!W%aF~GCS59sP$-;G zXy!1x|DjMaM4{B*E@(cR>5oz9;ToavF4^}RpQO;(39j^RMCgcvrqEeYOge=!hpG)w zp_D7nc`pbxN`Hk~S}A}kG@gptwe4jUinI!4B}>8?utIfU`gmmU`oeo-@d%}+p|PBp zCzw-|AK*km0t;OsSSWpn_0Omb6Wf#)8p6TusfE%)*9n>nLb?{}C;aA&f`<)G>*9># z3st0lJ91sgzYJ~R zq_>!%PGkJbDUkx@F94pz9mN&0o1R3{Ic9hLXC5a-&^FUPA$U4aEqTR71FR0Cv!_eY@X3CK#iF z<6b8ngHDdaUI|7UdXvFvODCam}bVHrvr0j;e z)6+*}*mdm98(LTz5@nVD9L9b_;r~A=Il-aQXMgv)^I@xSD0J;zheOT%7SMWC^A3+$ z&pQ@}N>8=u;yCo-%ePdlj;+nVe<+9UYA}>L^|p)xl#vh*RG-(-Fo$mU4ITz$pTWK0 zITWFUnr-ON{!g0W(V-pZzUW}np+=c{pAPk!rY^wA71bx{*R+1@&`x$}KK0KYESuF1 zU4K>syIv6Qh#DLEU|b@rSX#N7ul|n(@_++BeuEM1m4Ce+&Or3)g8Nd;Ll@2@N`@dy zf@oG*XVpU?1}*^1?&5y}H;6Wf7B;2qCrU#gq6gQ#Pc*u_9Rtw$L?~oLD}BMlF&{lk z(}+U-_7YV9O6lzZ;*F>vqJ+_3PiDb9-+%D_>Jc@px2cKjutV~_xN#Ih@3*A1P!c62 zCDH!iw+ylW#fuk^2ynpRr_V}M2cXNM@GwQHi4e$sQkqD#g|^Td#&{|EFlFx&74Z@s{eM{L zmT^qIhM>PDj}3(5j|huQG~8t#lp|VxF17x)txtBb1vF6(RX@|;_HCgrqH`;C|CCG~ ztBGoZKbrZ&g%*E$MKrIS=o^I3LsWJ=z=CinV1L}JTcP$4^U%9&7@I^Vjw_G zd7)s2D~$IaY*VymL&b;qwv}^gf)86!2y1f;>d;-Jq6?V;J}F8?ONeNoz&)tsyR;5( zU6bwA%HP5NuvOGi?BqE`yNU)4fOtDVEfN5xlI)*YQ6(PO6ZwNgAR-!!xPOq$UeOr$ ziY9zTO}?VC_|oP`@byLSaQKiY-N!-r?lQ3A)e90lH4FlenTnby=Kx@3(Lc~f$bj?y zjQ~*smEFsZvuJ%c$Loc*D%b_hwo@U-_fn%4bq;`Ko0L$qPL2g)D#pG8?QDmXz3Z}- zU*clSrCfYi3YGFiX@D{&QGdFry4_k4_`8%(e0iSs5LlO7R0qYKrA3f4oo9_{e1@A3!^YxkH3adGY?IE8;X>4+N24xewBt^FS*%Cb7)G)f2f)m zO{t>biBY4&lEcib6sZCYMh^jDf>-1el!t|L99au^hS?trb==b|SI)gr?YH zhx#IYk@ew?+iQ_g4u2z~oVT8PWVApsD)e}V0{*q=uRIb~52*ZTy@d`^4Gi>a=p9cR zWz2Z?(2-^InPrp_V8m}Q{H6Zia;PW1lfwvaflC=xq|7K4+I71cm_BIly_JUn1!3>K z#b=PL_x=pzh)2EmR~8BaNbg-Mz!YE$=)Eh-phF&E1#bZ6y?_5bQjkG;@2Utozyo>j zA-02Jy!Y~YP>&0U_s(@Y28{CP-b0O@fRAC!9W^{GJz{$JMjR_icW`c0wfq#lU)T{x z!+R3+E({uAR7Xppy4u}Jk3c9hQrX0zEC zJIan7WlY3GvZF`Y(I0>MsqFq?c}Y9kj^NdlpffPPY)8M5)CU`;HF7fw0Nl}V={UD> zM>~q#4kawpA+mqaR?C>8#-ch9r)l9+nu_IR`Tytp+YG)}gYFZB2gUCvdF5 zqKpW?34h6pX4l8oyyz@&eqMB`E(OY5rE~%7oOdI)>>TY_s#Bh$ZdO(Qw3tRM#Fc$R-b==P z1Ga~Wm74_)Qabwdr5X>OCI@IbIwlan>3^sWl@{g?=E4ferhYSB>gY6e^e20O##ToO z-!DMc(NXarqFaRP=p@%sZt$%>{5nd29fd(&X4ugJlc8%KtZ)=+M^#kw);t+)1#U;- z;K%H%>j=tU8_L;_@5J&C-qA*5b%su<+0i!Fnt!1C9qnupkH2I3sSs>^&s@Nm;eXMw zLcs<8@BV!O`1jh;ShQ$IyPS11K7C_|#*X4aCF4InD)QWSbl6RmX>=c-oJM$A{b{70 zE=CPH>RdlLrzTG|>S%k9@_pN;_Bi#>xx>>TFm=~Mu`W3!L=kPHh$?>BhAE=$6w#U$^i&bGD~3wM)@iPYN*F`8|JO4Nvxru-h*qJ{vqh8% zDH)nTW*1T4yk{W*i9mM0=MuqHJ~V$M5@aI0LnEr84m9kjSdAzI8mu5!mptNY)_R(g z>wJ<&w61_F!kOOg5&fmv0OZ?k#deYC8H9q+NR+qDfafXKflAjy!=s{AoJlmMNi;9f zvQ46m9O)1zQDh%vU@II4O_Zp2De{SSdI-c`0AO%#X=F2UZ*z5WX=#JX=pugzC=d(| ziAOn(!XS(>-aR4^2m%QL1cD$4gdhY6f*^DPgaAPZAOt}W1VLDYV1yekYgz-JgADoi z|8d6tU6U`Os5&3Li8v*6Q34zI=p09!&`wVMAK7vImTJqkMp2(}M9x57`!)-=GMs+G z^oi380K%Di8$rVomTUOH;S7Iys8pN@2K%!m=oM^VrMym{%kZ&Qr1Z-(j0v6n|`+kv?7ttDUj# zxH@ADWf_5iNrZLE4AOtUm1@?V#EIx#c{tU{(H&$vlOA{ysGO!hBe$=AtMQD5FrYoV z-~vX@)D&0pX0QpvUq9&=%Zyh%EvE_aWrh79)3%zHxwh;$eeu4xVo&8}S1j@4j{?-UqbE(9+Ece`AZ_@YMU5vm^^= z%=++@=%NvbL_AG}A$r7aF}#@lq4KEPt)zL4_d z&qD$QK5Ec4<`#UJsl&fw0svF-;7Bjy`HDi1nVxuld9nvD70mPAZn@COkHb7y((oka zx$$Qxuz@@9{a$Pis|bzV_lXIz!luTv-0XTeA+rGVo(T^R>v<}v^4e?n zKxDR|sVskj3zc{az5*w2BhUxvIR*&Y9ng~*Fe|%G>3lZS(_CQenE&Aw!iS`<0b_u{ zN=h>I#MXGeF2xN`J-4JTfa>W8Zsk;}XEW6^JJmBA$!5b{^UV)I^ES+>Ig;=vk^-)t z9ZQwccJ(xw7Cil{XD;lCu%0Oel&@IN>Q%7tQ`Ub|EbH0(U(dp%_1tiw#1q4AOSYa- za5~Yr_007PXin=8eV8)ZTnCrX5@ib%?Fm!K7!t65K_W$vfCjFuUhwDG z3zC1azH$BEXOw#;1k`vDaERiVbA;+oA0dc57A2xa(>>A9dq$1HAOfwOGXTq^Q9pTp zc|fi$VzPRAYK|B*55xY;j;iD!EQ4whto!T?90~pQptHLXY=RNy(Hwl5YEVlMxcKx8 z*?~aB51$Sn0j>Fcu!2)y*4&4tpk#!krYC=?$awlh_3jj_Od{J*-2m^G%rw>UNs>wX z|6wU6LHv*C21f*Rn#}?J2X;)N?L5BEdoP>^$!GgxHYwp?JSXWd?2CGM{9tr?CxC9Rkx~Cobv=7=c96r?CpI+d^;vK}w$;ih-&D#7KXZ z|MY48261B^SM}LB0vsE2euKz2HRf7gpF!#M8TmE~XaSMGJ|l^Nw19STTjnzP> z`MV;AV)8J>(e?=h*uMn0klZJ?nSB1LF6TZUKcfVMA}nu5ed>~_Vz@RC$u}Y;L=KL- zrggQ?&v=DU?^A}_;QWl&Ap`AYe_wyT&nJ5~J)Gb@4E=pd;&62Xe*UT|0v-4Ybp-)Nt`Nhxny$zW{<5m)1LaC{E(hORV z5zeuDH)^E?Ws7b>Nhr^lZM$PYN%>D?c^!*Z+I@sq`NQ?Ph`BJ zP({-OkU!G=RKgliy>2}WsHBJk+@bSR2Wh1ZwaOod89AOx5zYFa%c4502Ql;$S=vLV z_jR)!u{E~dH$|nNi;2lMTX6gIZkvAkTStLo)XzccC;NcFsh=U>tdRotFHqe0h@YUU z$n|q~cZ>F+=DFOGbnK@J321+`d$r&eUBX!L>?_gslQgix?I)EyJQ)q@+4na06TB*Z zP{)g|%=fiisDW$m=LUqd2jHtI>wlkQ@t|rhp!G%fsDHsu4j1dOmdOG?R}Ak_yEA~BS=q5smX&_)%){S4<&yDz~e9B{Y3`= z3qC%se{OX+c?{eCRV&PFgsE&$6?x%d9h_WWn%uMVXGEBkXTZqE&K zCSRx&|FfYqLcXe1*v0?Td>bpEdG6~!pTE(~005=VVr6Ri7(79=_KN5HXU?Gle3of>3xWp$m9C!M|96Vy z?tt&VeX8+nnaBc`W0-;j#FbEGTrE?T33t4+itP&Z6-A!HfJ%SI9biP$5R4g6P7<@L zKk3l99Ye^>d$=ftyy}|@hJ+dlK_&Yfp()+rO;2vpdL8%K8q2~^I64)Pb;HSw_zj zXzU4eb0h`|^df(gI27pRYDF{(bU>pSiWF$aq(EUg1}yC&F04ug`m3ZtpnByD$e~{r zTY-X&A-(&Hf7#RR#_85t818*jwl1`60kdMpl*2%;!TtHZz4^gPdNWWw`NLTR%)|y- zZW+WH-}XBL!O=aWjn8lJS{76KY@czUn^*vk_3zy)qa=U!t6waQ^H9K^PW6Ev8!JBrB(9d(7lJy5pp4mIdkBgN0!<<)-Iu;r5mX61_@k6D zf^H}0NBL<4T^}t{KY}6v6pV=PoU@Rg;PB{&CqD-A|HUOT>D<8t4U2un42jv#1np>o zCQ|OJ2?~E`B@*{gC#b8>jN>}1ED*2f8{Ej`-)k@?u&e@+~ji)-n?KFUl_vdLnFBoEN#UG&Q|QG7W#} zw4mxH*Ru?lR$EYdThK4ivRhE1T&v-Nt{x(%92b9-Hp?eJ|JH zBoqF+3|d9r{G7^YqY{;mCU!TIbwQcn;MDS)w$cJ&211;pkCh53o3O!{Bd2%W!`O4Gt{!vctdQmo#U=^;bk zZ5&k4FI@cOpcdPdUO8xk(6w02L2b~+iqmt@%{U}Q2W3YGeIveGI_PHQl*Z%X!GnUJ z1uq_ptfx!LO*}%sAw&_17cQZdt`CZ@#9e=uUw+W?Dt3p0_JhLrgOZIu;mn%`d&pVL zn11B~p;vJpy>gQ&MK%?LB8yX-K3ahg&7eDJ`bTC6g-AKs&#@j11X<5tkyDKq+2Tn^ z5Bpk>(8Ad{Ck+YJiV#$z0!%spNK|ZPbVp@wX>Nii=t2+#GcYWir4R^W7>vTY9}<5+ zAP6J~AP@uyf+0W%5CkD0A_NIRLx2z<1PB3&ASi@07?iej+0Frk)4~&d|Apq{QyPEw zo;Clg))7vTTfjttWOh5`{y1WVmd_4=hLdyz`j!1P5494Ys zRtG%4U`R_zf=n;CvO%khZ8rtu!(o4)ZgRvp=hV!zr0_8_qBg5vBX}9HgR>e202JX( zsSL|@&VbBErF$4?A7JU0E|W64QK*%X!|F?<;Pj!4PXK9u#BMP(1k*w#MC?|czqg1s zyQQ8;VwS*+o>WE5sso2b{-va4zLvB~q8M%S{gW1!8Vqg!HbR5cMQ|r-+rxh%>((uy zq0K_@-q?T<<(&;a0Eae=HA33wHx2NNK-T9ca*(VKRvbTChj-lf!!-2_gap-c$&emx z2&IS?TeQJ$0ODxv>D`8;H4%cg5E9W{V9uZNhOmY;lFZTOQMK`XdxdHRJzc*hzszYz zT=b_U9IRIi2 z4R3NwOQ+*pz>mdcH9&k4Q}D}e=_M~>MQ=?hxC4?@Q!wot_=%NDzml?=7Omp|gv)}% zf)T$x1#SVMfXz~7>JNSA%q(5#4}aLS+7%PUFsy0Q zvLe(ae2{Hg-EGtAC}q6MpGYtRHwz(i42h3(0gTg@fTC2)t(i2@4GJ-IAHpa|ABoYs zhYK98Dk>dqT$>XZv_`Q80@0YD#HU6Vm4@DFJ39_vbuHStI+uT|0-E$;w*|;@D}Bl|yNM5!V8+qHC3t-^=6lH{8m%2KmC)qRwHNs|GBbdLFSpQicT&9(j0J>uZDbq6KE-o5||~5WWJUfX;3$ z_5%s&^(Cl}jVx_PQM%Gh;u2q#@}J~766D(AvJ`$6x%S(z@g#8yCxNvzY!gyt*n-JB zvRZit-|>IEK-bb3$R4^@_1Fso#4EhVbZsruNaNcMWQ6Ev>ge935H{}DgL1SMlUF0t z(ROY6UxMSV6+Hp(Fm9y)&A->HwiZ|L+5tfH%8SI~wKG85ROPiV%WH{~cGEFKuT976 z>AHbL>oh3tRv=`?JbC0{jebZt7SYuTkDagOd)7|KB!@&HQy-y1ra$8fnY@V>GSzOnkg1UYLnhQS zhD?8(jTtg2BWcJKL)efBqjL?*`t5}8uaBr@$kmdK>O$wa0?h7*}WnNMWmH=@Y2sg)v=tAvV7NoEz9 zFeq1KTFzpTNrKTLlVEj=ObtaBnXs2$WO9F*fRQN^4I>lgVvJ1dM=~-^5@uu)M4pkU zeMlqI)=`a2&IUFz)hFA?l*eSekqM;F8Rm=UHFknAo)k81|pD5*hfJ!Js}9mv_uw?Nds|6rZgoYndS>cG6^OZ$<%*a zXe3jv(UD9X4z)QruM$fUj`tT~_zfW-2k0&Z#>kiemR9!RNDLvIWmRR;#MaJ#^v$tT z3Fl@a8s(%DPFZU!EX!$q*LQAGQu|EJG&=*iyu0k}=mlb-N< zo_Ra0A$`vWVdTCH=X&xu7qrfP50QT}%mwapZO-N41n1$*COw>SJ)A(chqHVSC&>74 z28V~#hZD=mNUDDx*2eltVS5h`Y@?*B-cb4hR5t(!X@U@_;y)q~1QZMiiARDU#ZV9f zajpjvP!K>6NFWdd2mwNX5F|u|AR%Z76as_*M-ZZ+A`UI14iF8(=JrQ^b2opsTIO|F zzFKrPp^*nZ@~T7fN(>gNOvSR0ldigyB58}A-{bq0&scY{OeEAwsXw6VMRtC49VgR8F(5pb+t zY{5#=P?W7_GNhb{dIDMOE0%wPP-`|=%TSV?Yt*QJ=U;OK351pkr+-zx)3rvjkj3y{ zWe7J^&V(J-`>#mOI#7p@q5riXtt!^2LbQm(|C4j)BD}!9@_`w^KKJyy8hp<%N_=sK zrW!WH3m*h*G~*s@0#xk1k?b2*IbzHq*ogub`N<2_*n3m@4V?!zaHxL)3kT^r2SIoP z_Hs5VH8?6$z@ALDa|Ft3g3sf4Ls0t`wqEc#YD8cm{X5Y|2yO%x<`Xoi;nk-NF$EUF zE$Nj$eyYb5Uips$YpXGjeID969oWX71JBD2EZ7cgb=X5`99BlL#+C20rx)utHUQ5n zS@#*`_LNI#V+R@a4#c(lLoA_^_Az1PU9#bfu3J}aWB9zyvTu6h74(QqHqotdk(e{sV~|<^55H}QhF(v>kfaG>LRiKH&mLpU4r3@ zs^2I1XF_se#FaRdPl~(=xO?kR|G}2RYJW4zb&~d=3_OQ?*T}d0840`e9@`A#yH;pu zGXkrG^<%*YS~CNdF&O$y;}TKCDbD{U?7Ltz(2BQL(A!5%w#L{lmy3;^?u$^^BOmoI z!+)cJj`n2)m%@J%oOgG&`;zQb*zrXF+|)fSqanp9`&N>3|FdR^)$VHSzsBjVh+M~- z7+^1K&A;tzYhq4#XBjS!TAAQM4BJJYpo-+X4ZzeIr-twhdrBh}aSWEdE~f$k$DpTd zmpQQlu>cS3mKpzbhN@X++abh4m_V=0q+lb8C8foX;Ld*`R+?2xf2Y`oSY>&PVkCSj z))JmH|;p9o> zF>)u%v#+2FQTWEDP`qvI^*rWB*P# zjxCpr;?1#*w#{*oAwcQd5()f86MUBoK$3L?EXBxpO)%?N zp9fgOh1Y}`@UMl7-c)()pAoajXB(B29K0f{evbwCm(=44{p4f+GIs=pLV)sL@(B`H z78`$H7ot(x$ByAI+9LQvCLdIkYRnL6d*_;e>{+-6Tev>tkDk|D>57&CyQVYn_~6V_ zHe?90vWc9vXg#Q$w14mm&Kih0KZP&{*~_k>YGKtRAp+H6W?~5~h_$~$R)xE<`cm+m zJ*DXANijqAF=Zqzb|&uZ`=bp>L8}X)d4_*=Vv5Kn8&)7qCO{$!q3DAawvyTsSf9$K zp-92V8?oHvn53hkK0O4I1rgn=d90;kuT@HTkfhZM=j%hwRA=Br{)<1tR1P3rl@34`*1cK*@S}D64-l zQf`M!RO9tjvH`3!4u*SS`0cG5v1Fmq0{knS?#wp*->;~TCi`>jqE6LheY)qfg|Y*F zN{&qy{C?^c_P?qqOyoiE+r+fkQ(B3Wtsy#iMf$|PvD~}jcqi-SnR)m>M5=pFR_&ki z-KWyoCo7*ei!06$PoB*!hby$qrL})&)xYLGz~(~P_mNaRqmT#7p===~6dP3mF*`5d zFPw3_PJ;ZY&LfATEMP|kBr7Nf1PlGgid3>gUTB0u!;Rk>+m)Snw`%?=<&{-&V*D!5 zegLcC;m6IJO4fqa8C644{pGuqq~zDoK1!Ai&JvTZEY#S}I~U8cKbO;66`DldKW`!- zXH_^_t@xcGQo7@^Si$i1KYL-zWjAjKt5tl2Y}*-K7DFoOOqUG?qC0?atKSXGUjR^P zcw<&Fb}?mcc7xrQ(@X*)e~t(+q78O6=_yG5U1j8!@D9Y^IUHr21Tl7Zj9J2Idk4rS zrwi2$C$kIqiMh$s6X@|l4FO^?enjL&htV^p#^jJcCT*n6ds-1*Bz}A+vBMWoFnobyS_$^|<4l!21$syV+qL z+q>5en)fs@Is0zujI(Y{P1N|V80VPLU;ar8R53enu8(&#ZLrZMNUe z)eN({nh8wuaU$Nmkg@o_$h`99#p9_AT6JM6S%O=q#modfrV=0SlB-V>JD_1)U7!JZ zk$#Hf9^{m{cqxWNv_!n5>QgYJCQq-F+WV=m2orBtH3Eyf6Bku4eAYUIv&F%krAo+6 z?=3FXe-#`u#27?EO55oM?+2uVxv+Dr*9$d~*TCisQ`LwK zQWSSP1by37HPTw{^o0{uD*7;1MGa)61FGmZ9ax9bJNNINvDCKfunDJc2s@Be~@}Yd%`NXbtX4cgm$s@E5A6BA+(*c z$_GoHASR9nf)M&An?$6pb|)b(vSG^hvPSo;RKvXqU~){)}c!xdQ#P-OB0JLE^_Eg$GLVhf8Z); zZTwjR^9yYJe?oP+jfx+L=XQLo!?hdPgrqo9gKeq2oWND;qP(n5>V#7lD837Isb3T{ zY>WzWn!&+{{0j&1!u{_c63+j+Ty)={=55QOwU6y5C!MatlA?YCN1%R3e7+kfj3C-t z`B0IW#&hB*-~}E~5YLB`No9i~f9S2a76*A2Ps`e7jqNYemD)i*^{?$kaT&0AQiY$2 zyU>&3Mo+qLBrq-Yq`uXY>bpm<*OQL5&lrTAADOAp<~=Fp_SqeDUb#Dxlx$Cxdj&=6 zmHQc*>gkn9`V~0-3TUm)a=8@0I%g5Q6tgM74U&Bdvh5lZaEyYcK{75le~{&o&oz<1 zX=}?WGF#T)j=XVMRl4M=*tdOate6k7zS~C@@v;I?s#2;I>Q5!r4PEei zSqb5?r{$Mb_sdEmS@_HN=d38s74Gw^A~IzG7048XVVIQ!F&LltKHEcL)(E}+0U5Je zch{b`?VSk-nY9v`RohJ=f0-3+ESQs7%f@R4`L^Qp@Ue#FR-@4rh%$N(gEXs)D^x`= z9tWpcuQW+Kh~(_p)Z-mQh__kWFJkpLtn|R;nda>Hh;8uR>Wo02q|iEp2BwY6SqR;z!I8HP=tEmkV&6AC(WJY<6+N2%w{>1y-e^kA*{#?Pg1je%_ zEF(#t^?tE5&l)(-dV8J~98y+$(z8anm{0SzXO+rDmB;}dozFT1R(7?|N)3K54g9Qv zc%DS~v+~P9WHbH&Fj^y!b7q_h8F*k21rOMXZWMa7o;_N-P+&9p)FUXCs~c#UI(9=^ z+b_Of?PM!5W~3F&e|w<<#A6@A23b9^Li&_eH!7`TDy^N0Zt5YpN(qYgXCB926%N3n zzo)nGf@!rPrMkLcT#{)myiF?Yzo_uH*Nl_<$7_6s?IUO(zmGyk(0(*OX`eS`%E5`C z=bzK6{$TkpXQh7!uan#Xf<*S!;n6DLZSu!%Aly zv~WXfjck5~a@c?JY+5TnsW16>Voi;Oqx#4P*8+}0f8ZIhh132ToLDcWh*FsNAWyi* z%=-`MVW>*!_)s79%C<7`Gd=it)Y>DlBV>(ro5SBan!`q#fP!5VMkAs^SZoJXNvbrr zitkIJxd&=Nq(t*EQW7$Xph){z-CDMY5jL ztiYh*f6B7@J+a53Q-c<{Go-G#5@(_hxf%XG@&^xc=eRkp;9&%i8x5@~7G}B8KG#!Bd+74#e;P%(-#=G7Z>$5lazC0D=-LBJSzrZS z8;kfigRWE*1ZzL&N(-T@Xd)bH(;#os(^Y!tikT`lUPRXct$AgGP-k>40!P)vz8mWH zIs#|(?Ad>%YB>sJ2vZUP3%zdLBbO%s&Wx= zV+0;-D|S}f%wXW54fA@3C_N86__iuodx}0{wUO&y1#RYEAeD1v?15BrpYA2~L?{!RZJGf1DQo zD7*%g^zj#7PfT*K{pD-pZdUOf7w$y`QDK038znZ$ekY^xYMwF z4<-lh>Bj>%+Y0E$DgBlcWI5b!K`*;nYOfpm9SGl#UnN1h*(i;OcccU~01#nzf&SK@b8cLXZ$t1POy62uy&mj7PL&W!mWO zs<6-D+o{qkH>RyFfiuN#p^zoc;3yz?q@%9Cuuwd5(XLGRf%`{Fq@ON~X;e*&aX%+T za(>}&@J_E%7s7um`w;iILrvVoL!(nImpV(08k4@7FA8>F)4VIVA>bET?oJxQ-$dRrf7&n9&_X_`{W@!P zH5Gd%h$p{9Nw-%hbtBLGbh^Cna7Q8^?}qWDH+!{w(A!yd<)cWf(3{TV>m*fJf4{FL z^yBr0s}ub3V*XaETD%t4og}}l(5N(ON22kYjHMl`J!9DR7m{<+wi}H@2IixGbMG77 zAB-FP*gf~Ui(K=*fBN+pZ8x1rb~s2(2?V#IhXI z|D1lGh|8vyTBpp-gF7?_%KHX42JLu*5ae~U!XLKbl}sGzgT6Q-LqJ_q9LJ#IIQU1z z6QRX%oV7R({iBpTx;RqZ#c|9q4NIPZWE@eKmJiVwzA`< zMAZ}I8qa~&LFxTWIRw4$;G6JDo?Y9-Md6tnLju4tA29&0#rJ;S@eSB4ydusT!BgAX4>pqkq6h93izXLS6hD0olCf56{~gO;lEAQj@}{4g^RF9=wm!FBT_e)Tt0H>0ksCUezD=vW^s(wXv2 z4e)2(M>imN#*05SQbzwu(x^-8OiL)jgih#-x zPFF$bTwA2h`P$r{Y@>}jWn&+9>_6<}sWH<*?BKeMf96GuRacHw0$i}Rl5Y80)(IF1 z@&E?gcMebUpPCb~RNy+;l!zy!ToBF1KSQ~JwZsugsxGtL(SR~9*)kCLoZ#GHfbnsv0zHo%e1{6@;|=6stG zBW^~*e|rGfL2pLHy%~8X3^hppxd2kdo7Pbcm_d}EC|wcxcHlj%BIHvenGn3d7tESB zyIPSmqnM{Lj-Pv{^ZR#N!ROhDM^{4mxi@V(1%&U^3E{pf@Q|bz$#c0x+u3TZUA;&n zgDP_@E%(r}bj$2N3lpl&>s_7qdS3*gqu-HofBlYQ0KX#Ff_dMdplQ}sK+hdr*tHK@ z<`ghB4O}dCd!wR$I>9Sxk2r5F{0i8yd}|guhx+cZ_W#HQlN-Dzz-FKiHL1u|5Lcey zb2w&9On!?nbR*mQ%fU+fFywS{20eF>_s9f~-09(ukjL%Sjgn%@qg{J}9VedSqZfAM zf5$@oiUS_9*b$4Cf;5amzb;2zD2`92Eq{6!^50QBNNWH?TVO0#JoaC}EoT5XSW$xp zx}t$P1{tFU#Pw1A-&9%vus~72-%)9}sei{Op3yHI8KH`zcsoCmyd1?l#P1i9;xT-I z4$V_MssCFDNX2^&5q-qf*8S5~xEC1zO$VP2LC(8lAbJY7`Bl%g)qGe{C~^0B~<`d2MfYEOKdde`RHKT!F)=(Mty*2*C(MQPMDEj4|Tu5X|g^rxh@eSAAk9cqIxMB$WI9}?jq;Ef|3mhEJFhhdn{wrb0yK1L2* zoZ2#WgB#{h3oDxbyn{*bMAZ^hiOUV>I_3DHXuWhljp*RK7U>OYLXgmj-cPD`&}G#V zsru6IfIJE%;0=<&HEstff94DRgizvNh7Tb}6Q)E63Uy|~qTwbd0OJ8%Y_HolN7r(~ zkQ3a*B?P^z3(HU#hsQ7>2l~jk2vU3!PH6B)A~c74@`_$;a3+s>BG|Tsz2y+mvS0#O z5ZIRe1&&Mtr#vAgzPC)0`zV% zLceG(B|*_jf-w9?e}b3!))d>3)zvn#idFsX>RxfTFR`UA&n`K$r;x zNqZz@1%tp@sWhOn1Q?QWi!uRTgqTA2ZhNbQdx@fNU%8?qe-eLH*l!pN6H_Ac7=(=Y zsDcTjV;#Ryu(KU3_z%qKM@^Me`UBD27km1V%oFgZUyPqrXyp{8LYC`zee|eb5~-i* zi%0$AQ@=lX;PMBm&NgZ=&DnqE-=e?PpWCkjAFB!R3#gsweZlcU?K>=$Y=rTVVP z>=(pOe^o{gO2wx9M(gt}jzqO1J|KQfoMwjXsgXDF=zFuhp4 z)K1gvAdzebIY&->yn|HHL&mzWvg(VKfpQ5RQEOWLUR(XDbk244%V}l^);fidKZ|{z;B969j$=1ytoXT;<32 zP=>DjvakG16&fhZFDu+jn_xMK{gbGrDsK3;xl(tJ^tSdF2nMD$=aeij7vHh#ku0C(ZL zXsiQiAjHC`=tepY#HG~b3h+B`Urgd;0YYt1C5m6RNZU!)z%TWwI##9`!;<`G0tp^a z&y0sTPawNl#kjHGzF!jK$10G$ClM(lf7+v{PnqV1U}#a_u@hn-jc|0#OR&g;0G{*8 zipJBiP(XH(0upS(^+*+va?(qN0!oKam3p_a8b=Z^5?>slO-+Tv5k;wLPYxVA03c*# zY;A&fsPbnFG7tn31QZku#2|-JhCpYt0}?7lBk$Q>&x&(*h%Q!ZnXe zm@>NNa+wW*?iH{?C(2c8%2nou7ULRu?&s#t!-`fDyw($U*gtDpXU!exIcC^`E{2+5 zod#!CQ~&7wVau744*bNzGUyVKe*uoPx<1mXYnS!{**DzxBwc}O<2MP-0Pw>%Viec4 zWK?$8GxbB~-4K7ce14URjqzX2 zFo;9$FNN^Z*huJz_f1rva3c5GJ?5N>nxVbSpyATHNd-(56=mxu-!&FHgwW4ilt&qc z8gyl0{f}hHzC1)%hgDS3_RUGGW4C4kqAgr4l=08)jWcuy5*!kFj z>W$v#*OpjuzCh8Z$*g$1E&3M z$)Lbre{MghoIgmeo5EEt7>J+(9tDk2!d*GS3kRGk^d9~QcKqTLe;YP7?H#2ikSmdQ zsgmF1Ihy@cG#=qKa_(Z&t~CO@&+r?kOB;WBQUtX}ReXK|$!7GU6;3waR zs2B-&214-#piEI@fUs7&p2A-?BqW9bOCbn|Mb-y0U{180UfpK~On+grO4$95s{u2% z226ic?GJyE6x;2qe+d{G>}UQw*G7+7ef>5yKFHM)1!j^EAB&3q6H#JvXwAKm_SdKglA&cmsL$GFsAr#QLOz-T2pS#*}&e93v zSzH6_mfd~ne>nxxsI_h3N1sQp3jk?CBnea}hW+P}o<=vMeHA?QY40)PN(}ojGrS1s zAIaTzGO@@&?~PmpeN@YqAPfjBP%^6O5 z89I-e2{Xjp_Z7zK{^c2hLGQ~K2(~Uywr=2A17iUg)a2*ZEf8k_OIPMO5N9NY9}I$c zx3WW>e;b1(qbL!t@PgtGTQB^bcMQ4%A1505IQ#{PLM+>9A4jjLXO!;{Pa4xnICwFq3H+}m#twq>>d2J5a2O9gPJx$!REwKci*>Hyf+cc2th4}X&5=>?$uX3f ze=$ZX$%9_q00q25fX&_nMx|g1Uj@G`e(f!WjbU&aT>O(yTd&xRKv@ZpR!uj_j5wWWW9rbqdTv4~ zVUl)l=ds1*BPFfaU92U1u%zAa2W)W*eENdREywZRgDAD zi&e5qL|fHbQp7t*ra;qYP!k8AjEW8LHw~vQ28P#oUM`F2uLllnXKj+@xO^}>e_}HA zL=(ZCJ%+D?16yd|P2Y}!=p7gJvlhBU#ZTXaVT?`c_W#**LkKcUejEnSok#qL*fxuD z_^S``!Jdb+r3BMd$hve^n!eeDd<+<#@vS_zSsIA?2@qVEBgMPqP550Bt}+>Dv^jhR z*o8@d^fo2qFMH~ynhu)-W2OVUe{bM*rvqp1`a5t~xKtuon>z(Yq8)CkOQN9-x)bHq zr^eqUW?q2;K=^rccuT(-U=Sb>0uup3fDj-AWCS5V2r>eM06`D}8ew3>e-aOoP6t<{ zngj~gm{X?aQ1OEBCPkyr*4^1R2ZclQjL0)GKqwOv6uRJP&1m*bbSE+K`XMkY_SBu3WHp3$8yZ;v(pVH^G>RS4S_mIly_(L$p$BpT5JJ zr^hrt_tcO>t6EC?Py<=Me+rE7IYpBVvQJ$gos!VJQWp^lIjRrH$_|4fm1=P{-4x3? z7f`0DqhJ#ITW*uRcq;vwe@_NEY=``AHX5Aq1YQr^$xjGQPe>p<*g*joU<&><^7}@? zAJ&?4R8tK-b1~fD=At%gSus?5gu2qaW+SeYiAf!R*-r;x{bc=pW$2uVf& ze<|n>0JQ^ZUZ@UsjM@e8N7~odJAepl0RO-uJOJ7{fSIU)2S}6t;32Lr89G~zfbhoB z1o+1$jU8HgdBrV`2}cz<4uxU5v#!qrU*x&Gl2-AhlDN9zC82*0WxsOKBBkuB9JlvT zSoUEpf;oA0c$h44f8@OX$UCtKwE<%7lB5k-xB~}*&IWj3NqcAmURlRPrnLcQciVr7 zx($eycK`kPJj?)Qi%4I09AMEtTxKoy0pIOid{IvY0^hh3KiMNyo7PT^&szk}?H=8x z0i3@`I}yC<-m2*F603-)an=$JtqRQ^3dYyjzv~^SO)dWWQ!Z>_ln@o4hg6JCvI&!|+6=9?fXPy~*0WVe z%YiQ>1h>WlN7-Ff6=Wa~3Spjw))DV@$0-p)ZSPyQPC+h?O5_+8ac=m4)3i>Zi0&or zJZ0Na3*3CDe>q-L@CW&c)leX-1tVw}I>Yz1W{N})2>A%VomD`moLw1;vIc)Zt`}I` z`!W<+s$|Cxf1D*OLpVDvx^3}u3byA7f`X!rZh~cF`*SpGW+&R^9TPx63NwOEA2dFO z@dZVMVo)t+0@rGg=G>!+&Zc;OZy;$qB4rPs9)g9ae_{0KkvEv(lVF@PUga|+0Imk0 zzVs=&9>yC)BBsDx;hsQ&kzi z39>i?a;S~xb*W#_UpAioTi8&3h5D!<%KcNF0D++7pm8+!z?Xi2IcCiolJe zAa83jf8af!HR#vN^EZ8^hVkQBJZ2`OAbC#b8U3?nuok^La3D_I4YHA0BljiYB6NKC zNnwLZI}F#N8w0?@upC)LP#Kn+(lgP`G7>>ob7{WE?x%H13m zoV|q3>nq5_a|ui`G#jx653XM@asJJ5Z%;+-f8Z&wr3C0T%4>SVD5{TULpdTuon7~? zV+J6U>QSRq$B(z1qj;s-0kKqLfp|y8hQ)4MsnARH_e=HUyohULTiV>Ab8dEKWwv5{HRAEb^YS(26Yl+h-X+i`fXrQEOF0%W463it$&e2Ale7 zu#~zp_Xitn@-mHE$DvBV2RK;z2T~xVf9Zg|vDkT3?^^!iGXQRVs3b0SPzDtcE`GhF zDVsXd#tMyJr%{4MlZP103c0iMQ(uT_!2ll0D&MNB2Wkr0y4o8f)FqWe?mYb z2uZ;ZiK>jo^F;QP>gDQZl`86z{;X6Eij7kZ8h>UAh)Nge?h#sivsz1kwtK0l9L9J< z(g6k_`QN3VG0P>UgO~1e6=8>$26vWEW*8=q$$J2?|S z`4f!X8a+49JT6$68=+qKLP6tJzKgnZF@G-%$Wna)yhDD#g3d$Bl$Q-o%?tdka4M=I z7QX!QzL3Ckr2YjXjD{XFW4|L&N2wLgeuWNs)iY4Ucy$)l>xS@)Q@IewTQcKYw$S zv|4V;%_C6er5nl2y-G&EEl=|tvx+~ly}w0)1mY}zdz0MfWOiTt(ERn$V6SMR4|k&r zFNlo!NSJ(e*qCV*J*Lr+N>TYJHa(}tr?P%ZIli*^sT4*~ zT)p*(FGYyi z0g$9Zn7Y9CuIF={r`@>=jxx-K75D}8DgX#ffv~9IA0Y^Vf?yoykbg0RC`0QG55CQ}t5-0=}K|&!2f+Gmba7;^OlMa@Q37<96%fXh0C;A9truep1VN5nG zb^S*1Dt1*xQ3T+w*MGc;@2(!vy{oji))4dxyL7o@rGnzE5w9DUzslLl!^%guwX5nq zx%6DDI6Pb)wRLFI^na9ui1Lh^l78RX zokV%k)-W7k>|OYH*Pr$~QBm#%0?4>~%%Yr-7UfxV7ddM;Vt=hJ%3W8WwD?6CQ(%-= zFFj^ajPktgu3c9MAz6}7D}58BxO-c2u~Ej0Hps@;%rq4 z!#Uz#(#ks#bARy}PvZJu%j1t-?;z$gcy`mSE|rEW ztI(vKa&!cJhXimBr4VAN_RTC68M7ffE3z)7Q)q9T1OhsXKySeJFayH6;6fP!8*_FI zI7`_h1$vajL4=c13&1v<5Rgo?#b@Ge5x6h*PI@_I-$in)OFGoR(N~{)tcBw@q|Mq( za^e-nV}JUE>o-N&KPv<#Pxy8s$jtsyTNsfm)qP`|S^0$+0u~cb_tRcI2lJHxj3^U} zN_x_P1YN>?ss!>l173t1sQ3+G29yaH0WYYC5m-3Q--zGli4Pj9SVDd&H?RqsS3&(3 z+l3!gq0Dpi`*A`nW})orxnTjlH{8a6@pGo<)qe%e{V5P z8pJV$iGh7_=o`ERwKF$IR6l&{@=XwOAB^s>kQ;>b{ z+@Iqw1{RjlD<|_%XQrKcE5MO;v?xc3pB$KM7z&ZH!b&b#Z2DU#BurzBUHrGHz?Pmt zT@;dLs9?ug0%+z`fj~xB9!@ZG%~-tQaR>^+NY5;giclHJ;$@y-AdL$p+^4^!Wq;85 zC8PsPc!lx2r8{ft85(6d z6SJ0i(`i8=vgY*LCr9?vEi;8}CRmQm+8BM$7DbFVSK6XVWYPXIp&;m|jgEoj1Q zFnS@9bHB!3?ju`T3E0*N_>j)yHhj>Q>{v7}Jz!#;NLM~jM|GYRtfMv;K?@n6G-RO9 z?!-qW&2a;lvU&m{e;^|S2|+}lAOuAM!3by2BQC4~Vpw@{XfyUBbqFU;#ze&y#^HZy za44)XlL^+-d=j7LZFA52(;U^J&`LEocKDSz1XTs5>9y5()q!>Ow?(ILaJYqw8g}_S zzev|z_9yN088FOYSO;U4*Jk)%7>S{VSpU7pN(n{b2Dbj2e^uI0^d810LIt@)B1{>Z zZi6m3g3H*c$;;ItDTlhsX2=fs8#`2X{4ymvK(E$s{!v8}bS}gPGFCew8|pcZOb{21ECNy{KT*Z@8Nkkul}S33S;Aqiz_FXiyQgA!YSP5O7dqf!lB#ugA*<8>Qjr8mM_sx}3u!Moddyo(SWs@+&_ z(vvk^J9&TaXLY@@?)^%HO;nG&6tH|;K%27u^vTC^PM2m}!T z2xowlxZn>GP!NC+CkToq2s{&m)SOw?^si^`M->S@mm?zFFEA-o!KweFlD;lm&|ehWmZuJWQWCy6u%gz z25vFf{J-=k1y+ztI+_Lha56L`PB2*Px%YAcm<-94`B;G!2~_X`n6~WLc#JX`d$2M= z$x#Iu+FLLgx2!jk79U=2eqG)9lZ@=3e~G>VUai=6u)~gKJo)6+>iH(yu|wMw;6h8L z8k*tw+Plb5wR0awp8H1Xs?*a?s$SV(gJq-f6yr`83Tb{1)A@)a07GG&z;W>Chmhe? zhtvcr3ymTo@$~PkE#+X$lpsAy8V`of)BFkP5g-zFoItqbA-xwsx6eJJNS`kif9dB} z^sB#-epin4y+6ccZ6rOqNsp@@wW}PWO9H%fW71PiCVg`i-9M8a4Pd6FUB9c$G|Dle zfWd2=wu#c?S(JYE$6=35@)|xrg*_g&Q%5E}r5^)S`u>xt!=DROorQ?6>U{iTW`lm# zeQp#8AUM2GjYHcba?pSC=E(*ce}Tzl^r(eZ8&nJc0C#u*5?~Ne5ZFcVc!VG!$Osey zgn&p0K*A7NMGDmckw#`3yb40WdOWPB6(DxJxxTf5vx2A0`F+ zbLq(|Z%`4DBoHamu`~xtOHn<&_x$pL|2z6vffu9Rz)^Hw_C~p8{TVIHi>Mi|L~k_4U)Hte?8gr0 zXwpex(^iMrhU*kA>bR$Cf2XB7?x3?aWa~4)Ld2gY==Vvw?@eX6?^xEBrG3>dcX+3& zYcK}0KOv&}5u{pRoQQ~W9pZh{+MEP10!Kv1+LFW<++?9b%tFNuuZ4MZ7rkP3AStf+ z2KCT1l4F?&r42=ZV}=M9b6zE9CI#22BaXx?dMfr56C0vLxl!p-e}Ke17*Wc7AucVx zkBoD8eL<&wJ|Ntw0Xz->2q1umxZn>GKoCF>zz`$^9)hP5tPv;#LIDI+m=jNGnl;za zBsZMHtjP1mBE+*jc?)JI7aBM4=H$a=)+gM2I1~_H;TsMG?`boov|HgRIFwEE)BANC zLJgCZq2s-39c(1be>Eda&*+mouH*XgV!01zh#(-!`6&;Oc}cd5;Q@iPfEz|RKM+LO zSRAHnRrG%ge^fHzmb4NBUkyj6fX+~*CC5TIXb9#F${}RA%1MWgKaDFVSepY0ltb)f&Xa5AT65uYSk+|jIY3{%f8~Mowtpp~&?v_*Mas=a z1G-MqO^gsZ5+>F9X7Sr^%5{LvGw}W783*(MVG);|j37TOV(%&x7m2fHC7<|NNi$fI zdG7zuC51)V#zNj%3~EQb`w|i^%4!tNqs5_GE5KE=97*n^^Nl>*GC3R{))QnGsQsaW z;jfx;PS`(*e<3?99Hk&Za>Q_Rt6n%?sNm@#1S1H8Q3@$!e+(f6QV$Y95I_)M5G*1%A>a}y z1Qh{7z!10uF#%ZMsj|{GW6veB8=xgHelEq@3UvzC^3RHyVCnS`DGzaLv=Ra42al!j ze23~`hrpn4KS^HQy%#P(6^ZcIPt(s7Op9fCQV4}@U>XP-V40aaJJ82y!wJ;?EFTqD z*v>2{e{;1LSDbj!5)@NY@v{U~e!}Ale&^c)3kYp83Q5}M_9sid=ZRrRv#c&ta>ZuJ zm6VQCySUcmiY{|d8lfK4Ihlw;5lUdF#D%y+-3n_#*fIfanVmT!>L)O~r60x37JBO) zf1;jmT=QX#`kD0Y4VpM|N;7J%hdhuvCr%YifBY-cy<`g#L$dV|J%q3t?LSbm6)8Xl z(E-#8dkKVLJ7XsFQ%X{U4mjO@UKe6M6V2n^M`1=t13e$nnVIR+von)JUg)#T6Dw4{ zNp#vwF~HQ0F2X>#oKAKoI`^5Q9R?Dfii2_!aRC;(^*QU7RxQ{ClEB>^@aWCtUY2_b ze;`R@C(q|=ZMcBr9j1Go?&?PXOAr`x-(}+4UOO=>xoSJ6v-FtGdGuCX7U-q#J}t!f z0b@-_nr{G!K4+FClk%AYW2rsvI1Z%@Q%xF}YBLs?tvMw%HH$rK|6jfz-6+d7;mBMQ z&Fv0VRI00M1d3e~g)@vzcIhfivn@|}Cpo*GV)rH#3et&&OvD1=Gd{oQ*ZT|Tq3zp$OHpZ)LBFR%4W8&;(e-l?= zHc7c8Gs%L155Qpe?+*1Dz=-bS+$YcFrJQPeFiTYKe`t z-izfRt#}uO6N2_i-n|E`duBB}q)&u!gG6c;gpjEbjYasdnv05EoCX>WCOfP(lEP!K>6NDx2}e~<_sP@oW0 z1PTE{0244E0ftcNMr#-YF%e94Ag$PU%R#lB@Z;}!V9nWHz(0Cag33JC7_r}xFx2`B znrlk51d?Q$xrP~+Kyt-C4J|5^Ku}ei1G&o#G|Edqaja`M@U+|p?TA;6 zEReRud){hkNuCByS>j-Py7)sJ)TJ%tWhlD5jHD_I%v~Ow@tU@_e~WM@RmXl4I8As? zV3u@3pM6qEo_O)YS(fc*0hyA88fz&q!ApT8n*bBwu~4}ZLQ9cl*`c!wxxjD>LPiPJ zn#$TNOHriOlzSuQSgsFBKjs*KWm6jG1xCaX5r?HsKiD*9mMC?HVw;i~$n>aNk3ZLn zQVVniMt@zHhbp{Le_`o;4#-UkO8WDCCMcEA*fEOygr_v9!=pl@WlgnC2eXy4r~{Tg zrAdI>2BK$0%8QJ|r!@G9K~dqWAGBdZvJJ`joY2|$zBvr#IK^14gaULJ07y_(aAb9G zWOQMG+Nj{^Aqc?;15r*PWCX|{sNE7s5NHrk5M&4tg1ZC=e*r=e5hw&U!tew<1)g-3 z)^AxJ$yC%0NQIhG&ngeb1&V)k4l>rXjgLylTcCJ8>Q0q!Ljs?ZxdiGh(sZe-5bdZv zl86+NCbEJ3Qc;r6%_Hq$`_=`dA`fSG6se>UfN(xC*Q z1|x2v(#B$2e+gVTLk5^SKkt{D*gl$XA6JX)l$@)CVR*^bMUO9*dDP8Xh+f2i8$WBeQU!+!#;Q={J&64nP8 zP*J55ZlDa-JfGWrmkGGQo2!+1sQg_(JF%P1=aGz^lE}HKTQK5 z6)dR*e<2`J4souT1vp*IpBhVlv4~=J?v?(#(@Xl^=*Sl7@UvfuaqVCmgYK`i9{)<- zC4c|^mEP;2h2DLNRbT(KSe^KLyA?Tt^o&0l1kUK+`$slCS}4`%W@@v(ANjKO!rKT~ z$n9URP=jcEa(Jmh=qCo-3;EBuD*a?_-E}c#e}O5(gP85LY^8==iwO$8gZH^B2rM=B zb72qaHFEzaPrJu!exCr`_L}Zze2G;dClBQ@9obmu1%MDQPb`+$U9#RTl|pY9`p!0a zrlA-G9zz!0Rb>wODv^~5X4-J_ziKKgX+V0fto|TRF*o!GLgG(`GrLE9lFNQo)G=un ze`A7>P3Nw+6ydcIP1CU6vIglS=gxuf|3lUXt5^jB73d+G$YoyGn;Z;|mHGfd(q_;U2I`5a2&Pt0 z6od%?5NUHtz(#2WJ!pR=vUIKD2+rsvfA;4W-s*P4NQk&g8GV5ePb2dkwhD-I=yaoN zEn;|EW26|~stCvnF%Ju(T}0e!EQ4`Oa~mrb9eTM|@r82e)!$y{O#mk{)e>)k&2NG1cGtcB00{`U~elcJe!km-Iy2-g6~t(BIY|InsJ1 zVooda@7H}h>Q#2ZPi)l;hsbJ`ghUjH@DxoarKu-*V&rV#hOj9UPA2s~Kjx>MuyviC z&wKY1%a}TkYx!wuDDBR;{&&tWS}O6`r=QpaPc%jRG%!5IslIjB+1Q2Xc2%?jx!%sF69YEW?3xB}KY7w}g!% zH6_Pp6yX~o6d6-d4>DPHjn`>Boeup#HF`&e6wq4rP68_7fbcI{xs^&dQ?@O>rxMPi z8<{CC!AeNa6?L!J7V2hq=hGaX;w%$!OgJCiO+%WVt%e`+i!%xA-C$x4FM?!+^9<*Lk1pnA8zh*p#t&R z&CraiqME$SNLQ3AkiZlpjF54^<>LUGV5n#Sz*r_wbA)0xb-))YPS~5F7KmbFes312 z)^>z85wCWB`{m|L(PlO|f&af&()tWFEIY3^cGTXD)ys(c8GrRM<_`f0jd7)f6&3mW zCVhD=AQ9a(Bl=Yi1w>OolDY~X&5xVEKlTIKIY$78-vkT;000+20G(`?|BnI~e>5LA zNc~tM8r0Iq#nka3HwY!t9%R#Ha7gvH)_GuS4TJO}>ZX8#?3Q5F@>Vz`R9HA9qBJ&4 z0hJOgZKn0tNC}h;g%SWDeJp)_fU~IJ$1w=O5JowP5D|$Yb3GC`K>$HOK_W;95`>Kq zCWe^+Ix){jF#LP>~>dK;Zxpg0=+vXV9CLJ1?*N+>PoO zE(Q}dGVSVLdO4m(on4^%X z#FB)LGoQ*#%mDuJEPkZ&oKrVHLU;Dce_8SH5}SME(y{>QU*&i|NiJU|xn%X=s+3$# z6De_kSFoBFEgs8X5AOBHe{JYpCj@PkBKV5%#Zb3N4(VF>ir|kvQtotR&WS93g@=hGEVOQ< zmcLdOKBOhta_*bRAI~0AYxI@y=qvn!FTcF9X_zk zNJF74*RHC=0IOO7i4dLtCYj-~q;^m=ig`5qRF01OrljwgFmB# diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 498a398ae23ef340ea58db81a63cfab8ad3f03b3..581d6d0af43dd9e46ed3374a2d839fc8205582a4 100644 GIT binary patch delta 114597 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 z zZQPgj_#y4$^^uApPTIH~d37v+*tp>@U3ZPE|9>uc{$byU3Cq4|^6P2bI|OnW2c&Ta z_`rjW&7YsPVVVR&1`CeSMI-`a4Yn-0sHg2J`u+tt-U5f>J#c)Yuql6@hP!)mQy6af zL#ipW`B&pyU=8FPgR3FiHYeQb4uaZw4wn2&qDA)oNC6zQI(tmQt@&g_K}UnDk8tZe z!hh8|?c)e{u@UZa3(K?tY&zOm*JS$w6}%Y2#a7BR3-tjZ+_qPs{}qILKd;#ytNp{9 zrdMZ>3kIAAH$%~DuH-%ab#RM82HRSj)zUUpJC#$#cxU5*o6-b*rUK}0>)MWV$OJBW~CQdJhMEyF4mw}0{F&aI5nA;|E>a-FgB>Wu)kzFm8X8CyReXEq$ZT+Svt8w9+QBD?Xhm=`=isNoidxsk3Q?G2ZPlz+7i z53Tf>TT^Fl`*|!fb6a*H!Iv`{*;St;%R>N3x@I%CQ!sakoj))M$p#g+(0mXHC7vZ@ zq8t~Q5AK^0wpGeVBVEs=ArwOV$=lWR_R*f?y~&^jwk+{yNkOx;NX#X9)wh0k+GGRm z-}A|Z)j+G70c=Vl{!Dl9@oxbkReuMKG279ifS`%@{4*$T*p0*8RG#B#`+h2I&`^OT zREamozAww2U}WqC^d5BX&x=;cYPo72ekZmI5&@-92Ha#A;44;ZcspG*RId7F4uMn~ zWZYLRSh;2k%&QhftJv+Tb*N8fF+S2=!4G!4X)H`U3KH-pumt}<4vh-;|9^hye3%aeZq__t^{SLS}51=rCF5T^bAgql;sR+S`=N1%SS?Vo`c$%+XWIhi+gKW z4{HK&cAaH~f3uqCK%tUL9)ICsUtiNMV#S_yN%wy&S?PYw!Gd@}ELrc*#>gz&14~|T4_izolfmd!QfCOB;PM79&MJ!oA@Y@|EumOzHB$)k=|&^lO#) zHl}4aKj3IjD zQ1of+{A-D57oIpxt6lABStXA&vgnuS!`r>|aaH4@qq3|DvX|!%mR-MDnMK;}T+7`R0#UgW4?-1d(`+ z3}OL&?TuJ9Q7eva{C`SiJpKm6>d$-;Yt~a9;p3v%dJ%%{kS_*D{ShzqK*m{CVvwRc z#FsN~b?r+KvZ3_sMl|-$+QRhUJ$qK}vi7VEh92h)VrJrgp`3p&E7?jqs>7h%Qqkjj z!y!)y$9mSDkET9Jt)E&^?ukB3!%ZlfwF!m5CKRwWp)Aydf`8P}!Q`Xk7t%p6(sAy4 zkjodtsgCSHD5Z1>r3-V`0;F1B+UGHZ8Y2=#UxiTQ8Rl@@hz@QBfaai_=PesM>W{9% zI4A)hQt})SvraY!f?F4H+VfPEe9u@*GEZWQ9c@a^w8@dVd+UGqA$GK$|7BRC`UA49 z=qnG9+;kQ%nWL70PKFDTcp$h1 zNCKQQ739SJZ>16O$4TiY!Oh4$PR_@$O!~JM^f+nXel$+tY@{*~%$H2Mz@?Iv@qE%? z4E3glM6@!-?~obL)X^nH#Yu2gauaeNzGYzZe9(LS4dOPfWeSCMqj6{VCP*9r?6VYJcawoPSb3IHe|?;kveEGUh-D?hTai>PU#z zG!Rs7w##z3(^PuAxn?QZl1`h**%dV#8Z^T zQfHiS$NS1%I(yl-8+`fjAhUP&0)?R?St!g z@_*n{WX>5eLiiRQn=#BmlIem@mXbTbj}GC*j~qs)P5Wq{@Jr{`*w*&e7^3S;SpwHs zmADnCqmsrx7Kk7Oq^=xe9()+)Dm89~v8+l}8^98%FKHfVH{cYn5F{-iRC_`fL=kpb zgoQNtUJ|U3`jQ9(3ct2VsCKqL!dPpQaDUDqzt@J^{hpD55 z#QA&u&asft=)t=0?O~lg9OE6Fk<5VbPnnrhAzAl`2wQXt$wP?_Dl6+-mHzIQeo#p2 zt(yOYgyb)8#&bfV-v}mvH*_Ze7=LbcaDidyAp|=RV+Ss+cYt**pQ_p1XjM=I!9L#D`SFJ0CoVh4-l)q9{>D0?G_B?$&+0dfoqu(@IuP#O z0rj*_#Z&8KaFew`)*|<8oyN9VG(O4;H3*A(T3H^2P83 zFb*PC3f)Ly^{B@;-ISvBKz}#%*K|Xrv2JKbD7&H5xEq?!cSEmKKGko+7PUZ6u^B6R4&Lx-lN=uiMsqeHzBq(hUUNr%E^ z=}<744jqisq0)Rh6n~khL&GU`s9dNH4YTUdxLh3yOsqqp(K(Ds84y_H? zp@fDVDwnZCgGqKMSj-N^^6b#8q#cS%wL@!RJ2aPVhl1wq&^+M|jf~u(;G8=&o4P{> zYImq$@D5Eg@6f#L9ZDwOp~mx9y(3$q51SZ6feMsmKl6#YlaU6DDk1;7#}*(<3oj$d}yM|hX#lF zP(+&##pe0YfI=UN7wJREoIW%$)rVr!`p}H94-IVgp}5&Tv{3Ftd*gj5M86LmP57bn z6hG89BY-HN1Ben+fM_-i5QPi^qHrc4nwJGcDU$)w zVjK|7<^!UDi9j?>2}HXt6o|U80@1aI65Z|+$&8gmni~=tE14{^>yMED-#&0>h4hXU zz#}V%mw&F*;JvF1BY6RI$qF^g7E4vG$hM7}3<%OK%T#EV&M1q~_S>exzDb?rkb^A21or0#_R@fCbPzgd2TSkn5D zRycl)_NTVOm0B;wudOIhVta^LNRdnX28(PAjoV7UDwkHp#H~cDU@w46{2%}rNls3I zVV4oc0U>|G1XlzIiI_yT9!dcpKx{IQE+;ne54S5eohz5IY1_x)#wM9O?mQry#%4!H zHZ?ylFWDrdeK*P``R{GC4zfVt^AHeZHceX{9Usjmmqw87X49lOY}45^xbVTd8UsL^ z+&GF_<g5VTEaN-%4i6dM$`O{{S!ahnd!vQg+ZB>)!^ z+-;hH{a~7UpU^UdaDZ}S)K@{=z8=67wbyXX2A>{1{EnpcOyMS?D8Feq0H?O7bjS~Y zw-SG(Q{0ptwx&Mn=kDg4d#=)MexGhBunFTr+c-?-5D-on^tUx54uU_ zc%{dQ&O(%Knij$aUV6ee1*&d(O54>3jqq+8g$Af=q2`Q2AVhXpY;`d zlVkM3`;~7(E(~Dq^9-C@NhJSnN!Lv-dYXNcRE^lK9=tgb!*5DQ=O4dGnjWxX+~R*Z zi(CkCl(@);_inOOj=zZuU0C<^H>H06h{w%GsKn3lt(EfWF8tpliGtp!4HO?&ruA0| zoVx!I71k-~6Xj4%*G)9&;6Pxkt0LH!l7{mz`Auf9ly>8fVh7MK%9`oCxH1Q~%phabkC`Q6v|Hb;j)bY{*Ft7UP8e+L{@szF=9WaRUAnHf)?wKf*@yjZ^w3 z(yWM+AH9TO^gkcl<0LQ^=YE{TC-D%-N#gL>vcf0RvG9;n#Mj6l;!`81876;)3zE~0 zHCH7UosT7_Y-|LVoVtuwH3znt;!hkyL^(x(P@+zv)C9#>4b-m#M z1UNKlP@dxnYF^EWrmv>u+(>^$UTAcIO}+q(*p8S|wU!j)YYr)1o#gExJSUegK#ER) zN7kOxQ(3R47G9@k}DIQQg%nT^SShR3PL}r0G5A;mrh+Jl=v{rP&*h$3ZQ#{4ShGr?P zu_l=DWhlBtExI&>Rx^J`mxM=`tRCl~JbW!eE02}c6D>d9-Vz6k1}5@rD1n|_A%ujo zCr+$Oh^$MP03#s5mm8@^ST14P7J`V}gW>ErqaZ2K-p3l6z}73cs!z3k)&AcKETFE11nz=NlkXY3S3 zr?Uzk4Oo}l~o{5Y= zhUq24boG^-mRGRWPth=ON3eJe*#pcB8zxr(;warPp(jlj-!P>~$Nj`%+9Fh#AC@(g z%V$qj5Mv2B?Tm~ir7ZfnkkZ6RAcWW(lud~{(iK2qM$CV*RCS{7DGv(kyV*ZEO!ZRC z4Y^x{k$5?U>vEOjOvV$#Fy#w4z9?ZX)k1tuv4CZ%ZuDl9Oy7MRMuz1b!9 z?hz9Qw3R=@G!E9^pBmL+{1*a+^2453<&VQO@Y7Sk zrx1(B^hhnWL-8=B-4&&uajAz%C+xf6f(3w>Ac5jgqyOZ&9RNRDhRGtQnOH6l-8g^Q z58v@I@jS?%5)UxK1*aX_G2BrSlRc?KbuXd;H`fcpKVM)bdsm9^y1T5RblI6m+ zHf9y$y`?rrS1e_pAH{XCg!H|GHYfi0RuS~qSUMV0gpE$=#c@n-1%C`#+PpXdSzj!& zl!9`%Mq#b(7we-)EK*nPcu#Y~*KD}Bhzgohx16OwBBx^|)&89K- zUhY}T7#+Zt7IXKOGh-JiqV;h3?7OxCAB4`Hl@YG+JTNeEU8jOdk5j% zbhsr_iAEQrNBbO+ELNq%LTK9ao zVB!?waq8Gz$FPe^V-$Y|{sQOjNkDzBPv^iPn9iAz7KxWyBiOsIq9ezbUK>%b0(0R# znC!^O7f%@eQ-b9XQ?ruuMf{3O8ucYlpbDabr13ifvVhulajGw-aKRhw`8Uob2_k(u z{f-8S7)0d*amATLALc&AG~Sz4r&F~&{#YKMs+H}}5YQZ<8ZdulkI~R9rVa2Qa-Qd9 z8Dq#9=$AD6y)J}CVN4|*RwUUK-o=>cqpDwv+NE|9aX=b3Lmvp`rMd$AsE)hFdQE4SH5% zkI5*YAYlbP5@3IqP;;0~j+kS`aY2rljPe69Br3=>ekBqQ3mb!r7O3TpTsVuP>fx)X z|00ucGZiNi?s&?@9GRZf>2>I?kSK&&htXyWmIXtk^GzH4Hw>5cQc9+EqDopN6E1QP zIP-ZA`L9c+&zj{_QJ75ZqSQox{trzi@5?)s=*JDb$pn8)Y*db_y*Zg4I+-kmOdqEw zlVeY&a=y_g6D2f&XcWeQn^pWYDD*xAjh}3IMhD`i%H&g!ahnPS)0-ZXQX_g+Lk>!? z9-EJ~nJB@bV>43J|tRh0oPBF6_TU zQ=c_D%u9b~<4kTwgc`C$NUq_4VrIoqqUrntPz&K!&%C*3eIoA?qsg%q5Hgw$*mQ?X z%oM*hny|Mfa5VXw{>jmFE@O4m9ZgPPSxg>H_47i3k0t|3&*n4O6lvl(Jd8HdWU75D zV(!C83vCUdDNR~Xymc-hh|b^u`4aBZL_}(k43K~NL;V(pezAv62cwOh(q>K(7Fje+ z%hI;7J?~cQyUlT&(?B3sz0Q1C8aq2^rzucnj!6FtR+x~0yVn`Y$&InDy3sc6j6|R& z^?qDNzat1bLpkGvB>jQfko3zMgB2C`w>Pn;q$U@GH!;STHx6{m#lTDVvg>Tn`k2=> zj@5r{#*I)-X^k*4{7i%fus@cc+}o>H{NJL0s%hP*M=93ji>jLF{BnVCzO1H~ClD8) zGBWl2=^r6nO$&$;TRy#-zJH9jUrmLh@PIXGK6&vs6e(t*)tNM(hJb2pDVWj5?d>;H zhqY4Mnr67#aPX_l?pbhfI_#RhQDamC@?C$E1k0mJcunmh;6NY{3<7@$ z0t^BO1T_H!1c5*hs3Qm<5C|LsL4^nrBu$$!^|wx5tE&u3{Q^)y0xrBSf^u~(#7t7| z#2oXEqlw26wP`M(Mn!j~v0;Zex-$#Tieqz-Jo;2FS-XD}7hf zJ$j7mWDFCXUggNL@}GUb{^~wT`sPX==|(eIs@{tE zmkSpZRY$a9yb9M9x~~&ESFpg|i7~=u{U@(rcKF~r2(qK>?b+Z;#mf3+9D#pRXp*Yc zx$TM~@quobT_CnrAnFW8u$M=y#>h!(9 zj}F0)lK#<#C4TfMew5an2>~EK+JoVFoBXK84M%O6R7fOXdQDSnH7bQT3(D3t_B8a-}A#-|;q-h<{)iXHVW?LV?+C>kpM zH}|^*fD|XP@%%&S+_8TmET0LSEZ~XN9zgQ5*stv8NtLd|nW`>s;8oTh5lBT7AXHiX zOt*l5$^twC$acn0ugq zgJVLJ6=Kp@uGapH7oo!-ZK$s7pYe>42c4w*m_eF&(J(mw9&UfFK^oQJ{<%RyslN!+ z3qygN=OBUALW;E=B(;uzB&gPJ50dRHmXbGfP?ns2;!aEnA#G=O)B|Mz1$qyTbs1M05pacsd#{vkPJ5FB_tdV3Na!5@U7f%LhAaDGgC{# zF!u?GCcs^k{f>V^A%(uII+y~EO@Ck&RR9?Qen(lzTPyvbVB~0d#{5isX2inM4Ehz) zRN5VW+r-$tN zL3&laWJv#Z?NKYu)54M)G$ivyQm>QP3-OlG$|wCe&W3-44D8m1gwTn!=0GU@7H~+t z9CnxrIXbTAvlcVgaG*n~Asp>f&ZmGa)~D`+MK0O(wR;fX!A@c=Cb5=T-yq=_UKRe3 zk_|-b>7og1@D@@}S^R))46P7SF!wBhsn(6FE2Fo!y#u$;8!C|s9tPkk;a_4l=-($w-jmMzKK&epq3{W9%6MiXuFYESmY3_FrU|n#GA+5 zyimX^lCCsD@~Tx>L|UY%!W6p{tHWtRaqojavLUZbc|P(?5RI(xeN)Zs|Kj?0n~CB` zFcKf|m|tO}MuOZGa%aF)ncZFH z#)$~bN-*8>0eI4K-?gTXKBgXDP_Pu#6!02{4>^GVK7vU5769uSNxAYR)5kyMHh(Kq za^Dn}y-hcKJGY|+LKwK7-r`7%R-`PZxR82p0u9g{so5OK-gjN72p!3S z=tzHSh!saYvOL{~W9mpl)seDJ5uA0TY%8u1-Nx9FC=xWe^miX|ryQq&j7?dP@=p{{ zu53SbWHEW95C@$=-#pUlJd!Q&4!e+q)FV+{t;p9S2{fRg*&~(OBP}^%K=Q8pg~joa zP$+4vQ1{n>boj-vkE95LnU&r=xV}n`)46~3bfRdfw=Qnkf_23dDHtSez|W%TZjRDw zXCbNE{!ogm=xpHPo^7mg!oM>x8r{Qb<3A!7q*el-MOP%rRzR+k`rRuwJa|qjpwW&H zIH!RjNtH;Z!vM(psRErbhbQQkUF_$1ZZP%tU<_enZtEf%vgD9Q7oQW9j9bp$&C0yjc!qL>PnI?E54mC=7DHzlEv*TwNf ztbHB)hd)YIk`d{`z&NmD>S9KF!Y zD8(Z|Qvn_Km9HWx`bSug!VybMpN(OscPIVWl62XU?Aelp!vkwes=`RVu5N!xdAFoa zn?3Ecr;vXu9e}{eYpv^(Gw(?EXqxar_V>wR4^EnY9{Dwuuy#oyp?|zf(%^*yyrhV! z`yCufVZE=HwEA+vA_Ut9Q0$`qn){;(Bp@&;{GB+5It$c+&fH4lTeI}y-)M$b6=wm+ zmYn<^CV4)z;}HZo+gCR+iIso+IpT^*m(=wVd@+d%4(1t?SQ?WqDazWI1Y1t4K zq!L;H1*#W%#x#?3Nft`fp_dTuQEbow@2)|Mn_a`BMf6_ce+HsP=#I`n2vc$pW50rm+!ZSk**f&W~nih5u zCk%%TK-(TpsaOTDe|u{}3H(!VqYxEJr3xjy@j+WCK_w<}`p8h~G7dA8XdWF6w5oth zC@57h_QK3%zH&Q=C_M0?HP`3X&$10DLvWVCrrtii~avR z4hY{y;$Rq4s&uPEOERU6%g*FzXDyRy0iG#2YXH!cYT6x8{bgX0B*?4ZG^J<+m&-w6 zrN5tKQ}Tb3Vaw&F)c#uQ?@bA4@>B&5r=+KCSe%k;r6y}+gD{m2To2VL-IQRv6dh(& z!gWpAr4V<=X#nWD;&C)VJsbG89K&%9aSZADbqwD{cFucOa zO8xbcY-vR?#c;Vma(BYXbq7#dD_L7BnbE~@hQxpW;(ZZiE0Nhsr6IQuI0ltuqDBAP zN*r#b_C9XRA06yLGh@&(ek&1wE2Zh63S22$bX8!2G}%EMF0K>@kbY4Lm>M-1uQNPm zvNJykg4RIY%QZvFrmm!`t|a@h?~C9x>ez$M84iU@7`aNQ$d>+FPZh7Uz3>&LmcrKg zWJiB3(krDPysP@3-?oNVNo!?S(qux?y%K2fsQ7Vf;5EuyI z>DT}#E?%6YC5hQUej9gQV3jV0Oo2ePrmW+UB6Z`&#} z71>Dvg~cD15n7T5$r1#$j*y^9mRv!12nBzJ6x76pm0DBaRnyXr zC1qz-W^ln36j=f)UW^%jvB_!<+Pi;>ke%L$5B&6=`Ec*U1d6qfr-4KCq9G%)J%Rs&%)!z3=nLuiM^B_pnf zNaIq;Lj%20!cuf`$R#W#H^G^dqBgJt1(Qn>RmjPunoYu*ptb{SHvkZ6X@Y+V=pqmV zW>8o#2{91EKpaH&0}@CO7{MSw5Fi8&f)G#%5QN~CKtVtRVIi8q(%I4hHIu4dP`5^J zGrXXU(um_5!?w%u*P_OX{4=2272>z2C!^jj15ohvJeFby1)biv0o6r}M|zELSf>tj z#Awd?nq3v%y^LXI^)YHK;|hPrGsvbvhc|7C^^+QxoK+muAO zw*0-dZG@tK*JY+tKZIAg3Az;Di;iHu`>Sc2|G3J_Y#7(@A_a|WoZ^3hwwZbOHKs(C zxmXfO_U1>w&Ix{!ln z0|W+j+wUIYCW`-v*R301k^=NYX6O?;A<5rR*e=@#AU!(#l(V2DjLoPQa-S}CJlWgpG6+Z@*?FXA$C7! zfieg&;FuRNsnK7$vCNAjj)Ey5GcQDbT!nFMw3z?+n{Vtq<@kJK9kHwW>ig>|`8%K#pfNeughL>4noV zkzuE@FN|_|otb}v*{!pwgd$i68}}jvd|T(R$c#S_f!5 zk|6c0Ll;f}=E&e7B*nJ@965pLz(EYfim!QB+J9SBudqn}b&T}?ix#g$HpD5q5TU#w z8XNABOU!fg92T#pv>s zuNs4xznVXlKg!>H$b6|}$$TjT$Ujq$_!OucIfO}Wl?pJ)ZTdxd+hzayk8WpPyN(A3A=awH~wjTq_{Q2G*)!yuM>DoCh+U*jPAke?Q# zL5@=&&%l5CW7HQW0|iNaIT8>*=e>{zqx!NEx|{!L?oGf7BzS!{U)V;z4BxJ1P{&NB zw1!I3bC#5J44VkpN+g z1ru$Ig*W6e7V;)!ERZrJW1%DKld;GlDr0d+TE;?Wz>Gz2GBXz9#%3&xOwL%S9-gt_ zY<_>n!Wa=6iw!9ni<=5*Ed0&VSd>0aV<9Y2V`0NkjfGFT8VfQ=Yb>lw*H~;8u(9}R z#>PUpn2kkoNgE3l#5NZG&)ZnY8@aI{GIe8N%ixX0zu6m$L(Ok2R4H&QDjCADa8QS1 zVVo(Bg@QGX1!V;}79PsvSd16TvA9f`W1)YEILD%M`5X)RAvzY8q;xEl2=+E86B!W3zgmmbWVOMPF~#EKC=K=v5TgONMNzj!iM%bkeOtr;+@f2!MGnL*IuC!y zEmAVXkd9wbw0Yf#Jc&djicPsfb&h|x$TS-n;F61(it&4P=R zIyvfp)i?%AHAV!fM1qW$p5fuj3bb%&Ez(a|P%hE|fUrS5jo`K=_?Xu~iP$jkT;x8k za2Z1zb14G6Uw(&R_*8l?#{{Q~$}$O17bQN)zT);QV%|5oW>FJyJ%>#-QTu=X=}^lq z;_X_OA!&k|zg>jw-Qe6sE8h0rUG&Ma2^#NRguw=J`z~7QFjaePEl6a6Hq>|n-%Sob z1wd99<*qR5h zE5dvZBTd558I?YQ{9??@%wt3nw4z%uAfi{6ND&bV_pC(sfWFR5RLp-Wo4C0|*WZyJ z_LhinVrES+yp~9u&veJKwuFGY{b)8D*yiu4q&|aK*a)2dV^W2hwf!DKi3{7GZ9mDn zSwEgA5=;M!J`jl*0cV8-#ej%p9)RXAk&yVU`U8MPhrt6~KfD|vcUJ298z8_t3$v1X zdS@|`@%TF{`p9B|XEA?HtN^R+nj+V*;u1TL*&eg{fKsswKS*&F^!^#DQq@dWx=Tc>!uzSy1@6?w@j=o|5$ zwPDDEFm}eGKgXhnt4`VOHrPU~{G$mxF%mS?mEmf%%no14QLtpY+Cg^j_CqlC1mLC#@Fi|AI}5Q=AsIHKl; zHFzppM}!;E3cLT`jJ8F~qGu;{sWmfktPnWx4=@F9@@@m(15?TY^&j=Pw1!Lio_J}g znB;x?^HC;ZR;4CmK37Y-#9YL**7({Lbke?;Xk`|hs84_VkgJ1DqBtyHn`#pMhfQMX z3Md&GLlTgIB{B;(xf*;AeEP~2{w%*52>N??;N1)zuy|ZX1#m1g+Xg#4ACZ6GU4htX zt+UfIgS2$H(|QnLT{!H3%-IL&Lfm~QFfhpRt!Z7fd$M*jL?mWG-Uct!txi`}OE~p! ztJ$}pTX%mLhXyF0t#%PNSL|SYHx!}PdfkT7YU;&FAGEqfwY5Y<>jxoNMNz@Y_`&&e zo`8h%OV{aZY4-4@rjD!@w5eCa4w|rouYj!;IqU2W!3_nrF0`UcO`{>L0SH$bdw)i> zMdhFS!WQpor__*x_6G#!74C_`*JWBVx9c{@7<*_^R;Jvlk* z2VX&hj!x?htN_{4g7oV_I4ERmoNX3$S!LqT#iXSwjxuKR<{d{lPkG5a)GyVOEe;+O zf+Ng+l&$pyY^VkFg9v!px-*4Df>&wz)9-^-EL<@>X(y>DAZW{uXaZdMO4Wjdg%hg8 z4PAf6tmrq4ON3OMB7Y-oeR>p71o6fS^wXjg_77#{rBqm$ZhF&}*$41V%RabD|86=L zuoA!-U=n|jT&peVGMa6^8Ym|Rx>$Y}E?%u?tBh3^DJE+h zTcM|zzdKiKiRDk=wgy{UnbN(zzM(*s(!zgLo_}R>xwSqh&A;xiYe<%VO6T0dCXD2t z^8i5>6vU#6ZruP<@oyAnF&5ZtGVcP?E!8ai{iwSYcKN{{#6}kMz+WDIw|2b!_d7q* zRo(YX{_xhBt$V-wsq9Mgevu__y?giWcRiC;w(gf9=dC^O&i&3uaL2~|5;DDYZuWoM z@7cxoC_vrz`+F6GPK?}dAui(e6aJ3<;QFa_e=F53tzR#<0NP`P^~()DtuN(|A9YS9LQ57&;9F9)UGvCb?v$IvhH-%}RXDNOsc*B)zyu?rUNbnuv zca%I_@%EUE#u!Vo$sNCvS!f$g?To%iT@-W&)pYLJ@s<=unQs*o+ z$ci~4&KZtdU!R`oR5aKBGcMMz?_z!`WYf6b8u~atLmUdx#BjHmH_?`OIvS3ETk@w_RFgOfA0NW_BHySfpUM*Ri}JZ z-y;(0n@LIK?L zG^0iK6)~PTJ?URvnIb>X?j8)m(*|U9sv_aBQ=OP_0$vr7b=C=IG_dZ-^|JEGk1@pD z3|VTLXSbyf+x9SvFFda9zz4%YV2i;||7_H6guz^1{R4dv_U2s{_gR0zLLcfHA9T{Z zuQSi{tc5+OBF&SO*@3ZWZQ{<}`424G8X#7bWFs{G z)L&4pR^4XAhnu|kD>=4D6M?aeyZ+8V9;U@p4f8M`oPW^6*tvqW219YL1?`?(itw6|8019@gl|%@9L3_gyFmiv1%os*$f$oPDzNuae zyMb`!y4q=c?S3!RR8ZhQ^E863w0#od7e|JN^0BXh&Tc-g;p|rhem8y z@~UjEXyP;XUBHr7kp3C&uu`RQ?g|ao!f^l@E8q-AQi^{ST|qLsBGW;KC0%J*t28nH zA|T(DGN&tZPgnBTo)mRu({=gGY0mtLSY7$-8&{#cgg!~l5u)K1rwRH;D##nZ=|_FB#bI{`|}>`2mFqEuxtf^*$U+UF4t60nx^Q% znroaGaV&q!qGz@iJ(nU_9evT04MxwDtT2z!v%I>RTznra9+B$g?s3wsWcxyi9`)rg zpEkR5JdtE*+7u%_f_RxTAWdVl;9O_YbG1oNc}S#mBayKYgeyq?aLNpb1nvUV)o*$;ivcG?{-9tOgoawpTU%Kv-r_0jx2o5*I zT-V^c>#`wArCT9tj_MOt^*G>@~_)6p5${?n9hNYON z&-b@7gsdX2@L^9gzL_8hLRFqdDIqoWUJ9cikFw2(;@Lb_C9t=*fHUP1rtKap#qUs8 z4~9?(iXJXRL)(w#+E;uh*k4SQ>wx2n2Ni#s9AtuWPz*o{nx_zRkgCi<`>6a|bI^hX z+#7CDg^wO6_^8=`@C0oj5YNk7mSdAK0b4xquVB{vnu}Ah{c#U?x@*dSF5`wOmi#g|6HyuY0|QRCF2IBaY(ff29K*2KswULB@bxYjBO zM}*d(&k{CWa2e=NSVN77?m&p!@v#fF8`y4l94YX&l)an=R0{gv-$_|a{KZ5!8B

eC(}6){>w%{6f4DRW8x7z#2X-k&?=KKK!q~)sQZLpe@2F zVd0=H8-%tPN5a8bXbZy7mZ1Bb3PY?up)@1jdJ3NuOV~xe zQmh~-c41Ais+(e=lI6S6r^ioK&ZTFvibd5p^VhUkAxuwq$xy##K> z65k9F-e0sB?pG`cuvi-Im9SX9vGWy+#T|cujLC}AcMMB8w*oTSpy>bSu*}73X+(aL z@f=_m>#!yPd7sZtqd7d@pBU>J#+tHNJdDh#Zb@TJgE1^N_pxC<#Gy2Tku@sxJ z)J(P8r+PVKWvo8+i~wjrm%oL76N~Tci?5)u-f{NVp2iZ*7H1N;a$*4Zr{AYom#wk# zXZ;ph$i_M?C%e60m52WY^(2>HiAOT46 z>a+R)CG0qkWgzFNupH~LgQ9bh1OKtskxg?kzUZVW8D^y^G02|-gDft88Dx<#f>G`; z+C2m#04QO0Z*X~mWcU(55I_(ZL*O6?!XN}`f)j!sAxIDqVORtt-c{8BJLzSFI-ja# z=wj-yOn=Tsl~x~qc*OZ^#O{Y!3(`jOl9aQw(chD7YZ>t|1KlYSx*^jd=ba3~B}s#v zb$diYJq3V=8D&7@!BcpDjT-VAQ4G93^%|Me%SS;q=<8YqUfDP&U>aYXrA=nV;Y6cU zf|@(N?><@HUN$8rICDg4jwKIs3nqAEF>&ZXDAsThwX8$_`4N4@lo_FlOiOgt}JIj zHV`SgiYW*|6m2sEt1<{KV;T`8IcKDaME}BpqYJ&0G(iDy{<_eFwGTV%kIo7!7iE9M zBFtIeVsFlXIhTDl`fxe+rx?PeAdxlxAGtPT1wc|(rhHumxl&pDmyvNJqD8>`wbcBu z$sP~FLg`*Xt%XQ`8XUAwc^kuCl9G{l&=JWQJ4dAOj!63CY+ifwbBu)KA-M(hf`a4{ zl4mv3W(eAK0EQkmbEA9O*2-#_fQr^ZV1xf-WhN@DV4wM>rmuT%-oFB*AHuc1?9b&0 z_e1h`ZLRu&bRzrru7KwzU-aB5TI*dwm5WeB<^HcBI3;p_a=(zlp+)U?5TI#wnHT%h ziJ@OER<~DD`cV(=DWN^j;f}=E@s>>#x!DZ|QXIap4K(YCy&NT|!q=6$jy||wjY{be z9rGf)$|r6 zYBRwGdZe_L&xgg~lFs`swr_wUJh|<)kEg>Jv}Bol2+H3SO1X~hTBtIw`v)Tr7^vm| zn7IK>5j_M`I+N4-S{8DClBd|_A*4=24im&vLOz;D6Azr>v$6F!md}AglFzV&5YHXC zQ(9bqPkEgKSkfvGSZLS)`T~^mbR5gRWtBU_wS&o|{S6jiQ`MBNKMINfl+e>%^8zO& zh`Y2u+IeJIk)<0tRm=#?ILZ%7SFElk9<5b$d}@>$is$tOv+B*g$&l8Ic~VBT>)c5H zTQ`4j@oDCXHZ^T8m{tb`T6c;)p;*I5I}QiYb7jSw(BW7Ha=1>7S5SZ!~1EOKdo zbY*39bAe>2(Mty*2*C&hVbm~Wj6sq;5m4v#Ic2sS19>DQLQ4ZJJH-oO^6sStNI{l&tP!MHk(Chl-~UvJ&zjHOgn!1 zj9@B*J7|jmH}sAtG{V7yW`20`#Ife&6x2FEYuW)K7>(qG-lqo?Pi-Tk6i*(1Q<@Ep zgo+I~tA%2PNE|;3?F?oMA$$O)BDaMQq#(5BFh3nc%%%*pfN1m-tSoxI*z+&izvyUY zbd-_ysOU#W%?#=28+{2Y8m*2rM>;ct`jPa1(A`tiE%`O-J>i781Y&%e1@fapY$T=4 ze~OI?F;Lf^xYIhxMhKy+jj~aHyBc1#&7pN@1_{CUv9X&JOv0m<5bAgNtP+ZcJE91! zgDEcWuD6PBFD3mgz;g_dnvfyCfqGXd5dmLvBR&*k!T_-j-6Rnl01MuOzYX<&h^PNj zbDxhJlI9FtZfI-~_~3WB1fzROd^p~2sP1kk_rGyIU2mvAZ^*|tB>5(P-xv2c)ckMg zwNC>87aW2O4yhy}gNXM(TxeBU!h3s7uih9N1*n1u+a4KWJP_SX3FxPSIAM9X{yCqTwuV<(=jp)83G4+$FzECy*hbRrlhLwsCMkABgAHXm3SGS>7IS%&0| zNuLuaM_&V3MGIYfx+Z$}ZZA}`n24<}ev57KlY;3%0XS%PzmRb))E_!a$_g#*4Zu)| z+Oj=Q3xT1)kIe0HFeD$%c}$B5m|i?zY6oc^M>=~P<&k;Ek0W1t$XMW17RzE=MTr)V zV7hD}@@ye(y5wtr3qdzC;I*f@g|^-reZW`i9v2cTq$S$cVU!06b=e82!+TB30WwA1nwS0l+~C#6k3T7N``{2 z>i|U|NkyUmaGHxkZt;p{{5^@-lv~aqg}g1p1fvwP0#Qr_(W~U{n590gCDeMn^wDH} zp@CRzedXqZrkpw5_()#DXa-_2xkqlXX+0X?Ql4Ty77nX^S`+rD_j>4T7pLGd!q zwa^hn+z0@F07-BF3CUA8q3f_YY?bM-(7s{o?sVAnuD`?n!le>1K6gGdS@k#7CHA*L zcXE{}vR?l#u_sAl<1RZ2imGMenr8ZV-H}{lL?+|UkZkJl3kLm%(DqsRE>@~{TqvLE ziAM`e*Ay}y?u$i;ezB++vs!db27@7*aiX@T(}S^pDDD%)P#ebjb#D+xFc9}KWOvRX zM%J{Ld=tU)(@`g%ph`w2q(WVl1CI&|3aFsKjj2-F5>$=LrDL6Tr2c(hQs)xYJFf`I zP3?q#YGXxJzg7b$ij~9ysV}pkTz@ESlzIA;CeFM{16)A>C~|3TZE%93sPg9`5Cjwq z5(|exjN%vway~v1KoCF>KoCH12oi<>K^O!f01^ZtNC*&|AVClmL0CjHq?JuNV?Y5> zoD7qrV3dX1n2En(^TF|a*VBRq%jUTE4I6rY&3ff6M0NWV$M`XAY@IwSS6zFH-N0C7 z9R2tT9LvKONld)4OLdEy*3HQSa=|eJYmqos4BIzRsKfkpB>f|Ccxt)8jz(Q^+|2J~ zKIJ6d*HGSx`l2BtBE-wS^|D(IIE-dmj3zp~h zXx`2nvdht&(&TD}`82l}b^j9%6I3%}K3&a*t(kpqzS+@GxB$X(LTYV0+DV81q&QKK zW>cdqFTPa9@tH#<*aqvU!->73n~HX9Dp}|+Q`-nHrw7K!&eptd7ogA?N;an!M4<}kpV67X2U1Ao`UDF;um~?&j<0Bws$p4`m z=o1a>9U+tBd3oE2of1ul)qXPyRJrs;EcP3$i0mw`{4DOTq3zVEjFG~AT38|fMj+sF zP!<3TZo^E#g>;(9Tkqm;;|b<|zF!L;HXi5w9Uq z@GY3A_IeZ6ED8Ok(fpG=J&u^RCUn0#LJj@I1+*SjtcI3VyMhqlRl=*sadZSKuUiEh zuu3=*FiwRh4u-ZoxwJVB;jm=2$toS#dt!BAQTR2G42%=;$Ce6&%Z3Dh;{|R`Q{==K zJEIAuk&E+Gt13wW$tigD$;APBY!-o>#Zp54XI;@$nHmkAm<9Admg^4&5M3QsM7ux; zF0s}#;24U|y6RBg?Tujrm$+ig8a_?eQtSbNLJ1RYp&GwnYH);`#0Vmrg>I97d_t{u zMtdZB)eE62{U?ov*Z{hJR#+H+u7)DSX?(pORS_!%m&60U*9>zGUA(M2lgi59Arqc~ zpjQldBT^={l}5>saPA^`8>2bCgr{QM+FwFNVswH}JPl#OLn9T@vV=2l_DsUB?@CfSo2s{yX#URBw+F4L?T1kw%Qj(Q_Jk3%UwSsyFu}t{G zb<0?10)N{`*daN&LWbr!PV~%Fh9^<|B1uLI4WF%hhjM2c2p=W9K_W^3% zd+RcRHXFurtX2x#(PzUzpff$DleuJdh<`vjiAMhG*3ky@7YF=ur?#PY7Y>!J`U~v} zah3#2;%cZ#5(i~}`o7jGnF(gqMS;WV^&;urXpTEaBwr{?h&kn2Qr$J2UB^3!Izg=d z=2iiPG@}rdhQ~%!g%CFi@|TFuuR1#*Mu*{N7EQsVLC9LnkBJH8pBXeEf*j=#s69vX zsA$?#Yj6(2of4D`v<-;%w*J<@L-q zkpYgo{%!0$dJPDcI1P&pqpfSnL?=f;!Ns9MAgo#&10qOJvCz>D{%0^<0f}yC`kwT2 zNKF5VjK~Ln>J+Nl)!8xi8gRom+oII);O315YFIUhS_c_&+&aSyVY&6o%SZQbz5U0c zUp6W@;Ie0nCl5>DLx8U=iLSvxA_ZXSGN6!16fv{_42g4PTj@*&2_qLJYJP!chF>q8 zn-d4!G0lmTH|C6HXqN2@k4#TrkI#1qxHR5La7G+|434CKe8Y#D-SX8PvM5dY(A+vn zfs`W}+-T=DKsw;>fMq1COa3FoD^hDM{SkMe$|)x9w|d16Gbz_n4S{<)gurIV8@G(O z**)LOPUJ7q#G;|}BcT+WDbl-prYYin%|LJ|a-rFa6!XZ8Q5O9>Lbbp9z)}M&_>n98 z2XM@P-YN)=n?K}5SDx!jO>L5j@sf&n?~op)qVJT7(Q3-y`M(44=(~ut^=o}@XV&?U zWU9PyI6E=~a3BINRs3hFD0mHjl)nG4p8~_2BlW>2h|2Z?Ge#&@5utrt?RQ$ufld`WO!$56+zGtnJZc>Jum#d8W)DV)!A^pE1`VkQ^ zMG>zciB_`~Q@sHz^{gldpp?%Gd?$7NOk46GB{#&)KgStH1Z>9^tf|(PmsGf7f!O{#&oe8AZz1AdmWs#G-s*U0V_OEdD_(yeZ;Psb zgxDosg=|Dmma63yDJTLjeot77E@I?rn@;Qlm$tJuWE?XR^+i-U(*z=V*i`sB z<=uk@Mq-~8ODBLf{}^2p|L@023exf)Mxw34)LW2+3f7Mm#Fg z7*ltO#o@VDF^jfcLj5dsh+?m%^%{SSFmt5~u#S~2zCqd!bhfuZW&Fj=TEj1m?8(U< z+m`hYGF4gQ3y*R(&{rKb@vpcC!jhRac6;TG&?IpHwmX30-bruy#80!$dbsAiwC3R9 zR9^jff*>qqaS?Xh5LKX$6%>Yle&=5Kl&JpxK2oW<+9s0h;^8wiu#u@|an3Fs9wEGe z?mMF9)534uo*Bi=A#hk8Ye*h067tz0B;IvBuM};2P7LjZmU{R zDd(wH-Q3Orfz9>nn_^)}QE=opuW^v!S)+hi9pU*#{NSTC(3g$RL20Oe3P7#0IFN$0 zeGle>&W5TPVHIiyN7C@6n~>;S)}0hMgo{12cyFO5ELBKjj2^I{5~C6uJwR89<_;dD znA*%R2>TKmEBYlO$NrC`Zh)`)X7ZFq>Tf0qz0nn?0SdoMv^LxhmoX(Kx~$wV6fLt` zWinM#jOX%KwyYeDD5NL{odJ-@5vT++nN_Ot3I z0OB_yg_rW&uvj=jW%LtrS7ew~Fqe2orx_ABqyZ==VoGFxV)TTgUvmN86q#{H1Z@lm z$*L*2#%kv-`8egGiU?kpzd$k zVIHP(77jt5d$D4X%M`H%FBgU(E<0wn^>l|CBFMXcF{}y{Ix(Xt)$Eqkpv$9kN(8#6 zbFvG*y8&oXc^&mnh(c&)Ktrd2?`X;3Um>oY} zmwc#y9A~R7rW(4f?+dXb_w2MB4iDcUTrISWT1kY%SqUZ7%c+NR03`rg@fG9A;KQ{2 zqBaj#kR#5zjI5N?#$17{jB0!|Rc@$^J{ zZl`YLncFyE2tJzTtIXG2M>v{$(hB)4PcGMN=mA&bfM+yxpFp?05febF`C8V^ZEH!r znm@pVycS=$;HVmayVi_$7+pTIRD+kDzCTzh==9Dnl5#B_t+}Dxj!RM#WAz+=6HGYo zi>%o^Ify+YJ|Z>AvWefmchYI}W51Iec>kv>z2s1Z{p07EN3hP0fIF z67xLD7p{p8HEsnuvi*y~a-x%SlZ}ijb66+85U82`ZI1*d!+ZC;&Qm~tC%4CPmygxD z6)>etZ~D5-)t2 z8BkAsnzSpz72b1ii3BHS1$?zG737fO>T1y3rR?Aw6DE6uy~ z09)#WuG_J|u7mKmvqBfQE~A48sAveJh)Ppp8fL9aTMtyJKl+ z+qWLIw%=M82%al)k~3YUK%L|3@% z2_ZO_DH*%FGW*zl9xmm7m$}-3B7%xb$FSXj$4$6iTq=NZN&AYc+BPl)-ngXxLt>VE zTtXy;s`YrzRZqBoz$i+S$z@(mF1vg&_HVTEn6+uwR9CciBG+Z4aBqonGW~Mt??aSf z;xco|6stC3P_fbuqKqbC)7T%g#99w=Vl4xrta23lMIUk=6NbXU++x9K`WK>!bp5Lf z-RBTIzZ=%|X5sV<@b;k?DhsvN>R6sICTY}KYw#_404s@qk|0+2+AzXgUW$o|RRa~s zn8b8f+x5uGBu=si2Zv3Uaww_|6zyCcQTYyh))|J6ab}~10Cq8l?v|&EX0iEKwg@cDhj8F|o_Nz4dx9|sHtY%U8V&tRp3{t+@ zSsK?-plO$S~fwdSPK5PPo6}D?|{?jIjQ0>WoK?o9$Ct4uURkOubIVqs&WDNnFcJ36S zAvnWueBY$SRf&(^834d?4g-FUuGVJH7g_)6J4$(P(--x5xMP$6si7$==$EZdbtVyi z%&^OUSb;~fr|2b!nmho{#290T>Rl^9o<)73sYo4!B=;0j{=`rPBM3TDO6uPLGmc=S z8p;T?{K_e|`DOJ%`UNUyr248ld_~E6_;Z@)g}CDI_h-(V@DrU{9jzSI5A5`6+ztRR zQgd}}L1%!6sNm@#1Un!|DT9QD7>OSe_!5DCLhxXMZwU|r6G3m>R$sx z&%#JBsXgnrqU)Z@H;(nAkNOp2s0hv;n^iMvjUR#_l~AmGV&Zz`ST0-SoW#hZ&=5;V zaKsaVAUP7&d71Rj;@Kq-l$I^ji>IV_W6~Y1O0HU@%w~hNEcr;J&1xkxId9SOUPDcP z;R3}#GW*R^{pDj|&XWh4$%Xy$K3?R{lS0CX&py=ct07zuZ7IhD5OjOhR74ktuwUgD z>^9jLSS@su3Fv>Dl+ftsmcSO3Za0-l9d7}zn=G)K#QIG`c9X#ZFnMNSe9DZatCBEa^=4_s^uU4`e|3Q~loWG} zp?K7|+l*}hd?jR(+WyUs;O)00g1q;TC1oRKQBr;Fq=mQSO;A9waEt+OsrazFl-d-AIEX@ zjJrn)Q-HNbrCe(?)yBCNvKM`SQl+&Ea?uHGp}93u9hiXlq8|Ee86%uR9h?hU_gLs@ zxK`w{CaQ%BBcNXPELpEWh;58_iv=e$_$}z_@(y^{;gc4u(q~Is6hQ1#+libS*tJdy!m$G9EIavQNRRXD)eyMI?z@9K zzcZpEI*n=m-Sc=}YnjT9ZM^g$s3BA5>wy0dbOMMVyOgJY10qlfqeZlR^+}|;@dww* z#qDc#E56Q)9`_;P^m+TW`pBk75vB6nq}?>aS@u<}9_>gt|8YMpM{n3E;qcrLj45BW z(Ep5BrOQ}RwBEifOv-pqvRdmK@ONE|n7kX4v!+{>{E_)wn4|tA_rc_w>t${4D;Qg? z-TfXfRZKU3YEjs-$HpFcK?6Z0V{r)ZF|AEVPuT)vc4M9-@mjsXKvsaIJLi9(*eGUG z{B%hRCU6a$VLJxXk_R6fCB$AR!CHt(B(NrkB#2d#kZ;6V0#6X$nA$}|RE;WQ)}$;c zVQ5KNUC;TQ51q{;r4FwzS}CElZU;&PYytoP5^w;2!kzALRCFGu3ul`;6!xYBSM=rc zi8$9DhN+XI6}S5+?M;IE@3SqJ%Jz3>sqJM;+=3;UHm z2D}9T7*%dzF0kO&apj@`s-L*-m0oEgQTyE>rx}|REh?C&OhT>q(hph8qQ4SWncgc@ zZ2@b4W!g}+n60wb$_n9X`QFp@Pd9+s=i!80#Yec~KL4L#sK5}n{u%*Zy6y+>TpM_w zqdE)EL=Ddae?NShXuO6xu$`RoGIZPVZw}8#125KC3V-+U3`gQ$y~?t`&!JueX#Ss{ zw~lt(hqxQ?rMr+G97I5eg9vpppYzn8z6)J{)r@}5RlE-XD0OmfbY*~ssPGvBAqc?; z#c_rR2oWHJ5cv{75LiTTLx2#-2|guA2oRQlfgqy6R;p5s0eeXZSQy>Nw#fXJzs8=B zF;CdguifD^zb!StS08eyyw~OnxHVY)$V)CVc^v1r=roJW-|`$+=gU&lkXQT~z{anC ziM_l1pI;N5C8S=WevL*Ug|AMS>#wm&`)dYCxrO6F=>Rr50&Ef`i+TYzvIf}XKedM} zU=sxN?TfU)cc*1B@O8UhmIOA;b&czX&VKl#GS`<22sY=cw!enH`YXYP0%wAHSD%2d zAP~gYCsfZg*dYJRDC_$KXxt`ZcA%<%q%?2$q!8(3KSmR!dZ5U*NT?2APeVxOh){lm zAWu@{Gxa`|UdtYn(`lll>hf0Ko|kdwq+&bxZs3u95FRYEB0kxCqbq^|G^ByUvoH`i zBkj$6<@-9c1{U3-|6^YV?%eP@Hw=l3aHEh%*8@D9DWXe(THBiLaMLnCRxP1_8U4B% zlqr~^Rav^mEgJ`7foX&S5D^(_L^cyVqQk}@?-_1}T43xGc#?&9>*8}1Qeb?A6tGi- zIRO4&MXC7=lQQk?yg{_3o%KN82fOuY2A{iFxr=uqqp0}rpY$c>i#5AxD0+a_LV!k< zLdL(WMa1+yjv$8_p+FUXhHU_D0C)hb z_aUVSWtSm~iG4S2eHf88vI>9ZPT`+!RKACK%HPZ+_o?yk-DEbj$Pk zuy{!n3W=b1(j#(PDkW5@_C1T24gqHweD%4cZ0p!7~9%- z^XO|EmKN1da}9|AQ|YE6C-lz6ZgKA2(gn4EQAzj|tWcG{Mp0#inhT}1t|{o~y5Jpa zU1JUNurn&&FI^^#)l4?>3eAO^{TEDP7-nDixo7|55f5fB0{070^_GoR?$} zRa_xIMJSspQj%~qA!u1KJ}eO+Ep*HFjbHY;6PA!Pd7Eym`Io5-U^IYrY*6+LlC`ij zPkL-0ol-=$66aHY_S{sn+-1*Oa)Ts_oCiIRg50R&>9>xfo$6R4>s^(5S}DT@SjHOE zdBJXEr7kJWjnr!iuw52t)v;+O6Ny)vYdW~GPMx&49RG417l{0*h50v4J3c|y1G62DM=^s0^@YD$(7??Kg{nm~1f5Ax27VkB z1N>Q{YvLDwgyE7raj4T|s0So;zL^~C%QS`0lm$Z=P7A@ z;;g34)QsYB&$3in`%7XN*0;gE#tUXKGwAt0IIhKi!CW#Cm8Yh#gzlFnpU2eba^1RW zF53_UBW@0C>;*;?D>Ha5CqO2`neII0p-7x`b*pBiq~#1|(+y_-8Pzf~ox>a8j%Muu z<&Z$fw`%HxMR0;{1#||cZKx6}3^A(LQ~VXqQCR9Po#(zu*|sf5xMzUp{L8a4$4rJh zzn7JN1ZwfAJ_OhhmWP1eB3l|6>@h|LCUjTS&hx52mI@u!^Pj@X={I!P8M6w)maJtN_Ys3v;tz;^%_jM}QWnndxvNT%@Z?Pe{tMtfx0YFR z+clNlz8!N+L&e$(!3jkJ6DLuJ= zk2AkI$3p}XXCVqL2LD~y$$wW5!wi~mw6J7WER+{u8LF;v1pkfs6|IM<&+oY;TR_XP zs7_Sxe#v3WgDgwWkd2^ZXv?E)k|7&zBdm8j$vw6ImIDmS-$+Sl z=}{lHsC1H<&MRHEY@+G%TfmQdJ_eRCALnzb%ODG>X5(UnHLr>NFv1K{71Zm0Br_G& zvnh05+$5kI9!Y^yNiA~fzf;*?m>2wfuM7_27_>y~)NlJuWulb{lq>I}Qr-++ z;{?^#C$D;2-f+6Z(E&_Wo@KWJCvdqEl+vMbqkbI>S77;K7L&E|B~O*(Xt=}<)qam$ zKvl0gXJ}}00jk8$0^N08q5?C2RUaqtQ&2IBU5RTKB%%CXlgQyZKj#hsIgY5AU)4|N zn^Z0?ut|Lc$q)#Njl+hq3jh%Y@;{Mu3{Uzc&OP@)+<=4nG+Dsg&hPMo8y_KO$%w+w zQK{F1Y(vVkwKn~n9J(!ZQtK}U#O!caglVG}0xtLheQ%mC$n>9CDvyU%oPl7`;S z*Ok;=N>@(^PSQ5K%cslfUrB&&f0{^W`$bl_B3#Wh>wl&MWB@^W^MY14OOrR6qZU*65z5C1-+(ho~GkOyL**SmG3eHPMBDC50zYx z2zr*r8F)+PI5NpTRDc3mc79fiF^)KL+M_mx%G6Y2SZx5njk9`xr4*|I%}5gSs@50L zS)*%jZM-JtaVmKU_5rCE3Z?jDB;rj_4YqPVejcS=N+huIBgxNRNTEsVtrw-AF!B;q zTj1LigWT~QnaY$M)1GC}oC7B?eP2T+7zG?tg5z#(*&4q<{Lq0o{_cB3(6%J6{6jG6 zP%ON8UR~RxRCKU^_G)CmI?;ROvlU28^VZ5UQFt;|huDx-yG^nv#{_)|?{Sta4BBIg zXxqw4#JKOK+Lc&Hi`Nz>*Rg+rpQw*u@0kDsEcVglB#~w~%S2%U7#jsIE^a;!jYLJR zsY{<7qg^6@G+?X%Xe;cRk97B?nfG6M;7%ZTmr498Hyz!7tqPgqNZ?WE(8oio)|)qy zXSk>HiErIqxyUr889ngg)0mAjdWsSc(W~CgNz`BW%yW{_!ih620TSFD^;ghhdr-E4 z5FGJ0ACjFOxbkvHV!)+vlN)LWGB`f)5^!E=HdW_eFsyl|4@;0CeSFW5aHYF5U&s$4 zF4o!~u_PmZ)pnGplkL*Z;n>sVH-Xk8B`8{mmGv>Rs)lP~v4{=^TB!93duRrHFNEof z-StQR;)WhSEE*dxbUHJBoltA7cFb9eiu6H4+D!F~zjZNT?S5+{j74$p!xt{c6*%@O zW{5N#FCF_g6(5tG3N;|GJ`V&U{%{|(0qAJ4PkfkvyJP(%SIlq3DHnGV*G}1Yhrw{o zfEYNkCvC06Ht{*SQb=KzsGZ1v1gy{lT>q$O=t&l<*pge|0x}%Nwf%J-H~tCw_6)7{ z!n5a$1FL3cJCq3OBnb~;T-^kUr&!D%xSgoIP1DAy<&M_^Kw8Q;lCE#FjDo$&>!}|V z|3IpL(;;XhJv9irZYi2f00{=5!*JhqQeltI<-Rbz{0_#xU%wZ0Nsek+dSWw-p_?*C z)?#smj?PY)sBJ=Nfw}uGjv$XfM(7tWfmCg?k~jDdRz09j^<6=nxLW)9&FOLL4$8ph zW=fqXp!;K8#tzt1$_d3;*0CdrV$xT7+tqx3bWLk!HTTBvch(i3YHn+E^}BuxV+fwI zg;c5eVRM{QrMnJrVtl-gmac}*2y`;&Xk`wdJYpE3o(C?1-rg$XOR{qR}$lvIP6g}EMGh?Z&4NIXRJ1{c6M z$+LD~8<8|2xobkE&U6u`utxmG|N7s5Io`Zg-z(gSH6GO~kTd>D5D`d@xYfix6%N?Q z7dLQ!8+6Z#T7xT~U;G2CtPI9ih~db^6P2wG&ZL&1hHk4he3${b(FT;ang^ZR*g{^H zk4OT(h09IKdeFduMFElk000?xY4Arq1{=4cSycH=4pRpsMPd@?vmq>m9^r3)U?c1> z#2LrgN;)i)#+=GO(bTENtITPYc)RLCo_rKNUPUnfvphK8zlOY17TS=}r+9BHC8{C& zf_c{Bu6(1nA)8EO!f5Yh5B$TxoByMq%HCMQer7{8-u#d<;klRUI7s--cX`DK{zENTkJ;|{YT(Wrg3Iv%w<|}R+98V2@1~Y@kcjic{(K{RSS^iJJ z^+3TwFI!m9wKMU%;AKpIlgQ{i!(EMIkBOnVPD=G| z*k!te;BG7~0J_E6XTf41cG2KqTBB}b5BW@mG6_Uu%Gy2j?rYHxoDo|K6bQhxcnAzL zsMhG;A*B@Du=Pdh7kF&Bhk0x)xZ;CB0OBQ;#Q+@YG%vFZf9HdLb(49i)+6jqUUs$1 zgMLk+nUo!T=I4atSDI^~4i!((qe!^tZ^s{OeJumaG0n+o#zJCN6`w{g3&ng0k}b2B zGP3i5EPFQ_Swa_;YxA>W9#3iXRA921u5qdpm-21rE1k_9%@>DPG+|s?8=U#iaJy5@ zD%%F@sl95F*0f`P=Qb+?gEj6uMKz=ekaCD#J6BX&{{ppDv12JOg&xCcOow#m?PlN?PWp(Yoz9GaNck&?GPA=0BeOEbC` z@kP8rsP+`bbfi^49b++Hd}Ot#g8-uJlLFRNj|kDcZOwsywaWt8NgaPe9iTXPP&mO< z?yS#vK38)D#Jr#Jv@W|XE@(WF?4h;Mb`}vu&wqeYi9-$6;6n?Pwf7H(qOYGq(VoDF zcnQ5Er=eT4A3u+{#`#V2gt^xyfubNNMNvotrq!oKlLJ)O@@(A^By|@I4(W;OSpzA5r-r8A-X0~7K}o5#&-H#9@2VLL zVtca5k)V2y3{h1Qq@!_&_E`_%C5iuHED9{El=;jJdM)DBvSL1Itbjxg*WD*gP%xpf zW53?e^u3uD*MsXO)i%UwBS1z!2%qx`*rI!^N?j%Z(!+r>UiwB6JSA@eKbY%A?Bg>e zEWpEm819EWu;ymTxRf+3j2~hf3jTr^$fHx`LD;J3ZNbG3rbpccm+xhw4h?< z(K&-q7yt~oyQXh!G{Ta_dKoa20b%mOkDe1Vv4XV0z}Tzis@Tln7b@6~+Yk;;O1YSS zaxEI>yZW5-Yp--K{kR+&(K)kn$T2Z3c_B%P#aDl9KLE`7BRl^c2us`9LI{abUU#~R zTqIl(Q^MMANM8KzL+tw6MyrOR zvvRwj!m);t`z&uJRKlDr`(Kz>Hl6h=PLgzEf}{f18dLF!NI`a%^0aLetY1&U8-Yn9 zxFch`6}WH3`Qs;WnWDj(g_h>RnF?o4+;wUCp>*d|bws);glU9r_kT&_{-KyP?CxCgM+k4b; zWKCMVIwW>5?`A%a+GK@!Uo-iCTymIq<%=Ct*%=@C_Azg|w?|LLr z3uq!3{FwJ$*fT>T^R7lVYIaTL-5TM!r84hAN`$9j%e*U+O~O}4{drKrj*V172}>gG z7!-^zLcXRF`XTm7Sbc#jbj-n)gd2pNxj=KU=|)*xtGa-kGae?8Abop(>!z(8Z-5p+ z9BkcJ;R1`=hJtZG*hLc45)4?Oz^lyYW4<;3i3=~~)}c81MY%`BA&%W%;;5Oscd>%{ zuQJ2he0Q9jE2B7TP*F#gmta}BnQ@!GEwN4S9h;5sRC&&aCIfa5svQQwR{nN)xM&^a zY?wJvg3;Iu`$+&)U^V`K8o$=`sDJk*H8Y%9|LQ<4E_>MLDH^?s<0Y9f7C2_I*kJ1{@9yiZ}aU>VCEeX0~$cb zz@G)6a}hG2&(dqW5RX@@N&^QA5>VBYbUdRUWAcI=uK*1b^#~%ftNBP;Sz3S@y`Va z37H{j$|iQ=-5ZglD%Sq4o7lE5y|eg6YtQfIugOWq$(r9z&%AYEbIBaZTBXQtQmHN~BsS@~PH%6P`;wEB$v{`q#P%>*gRk(oB<{=L2I(BhOcs3H~`oSDmQdv9yu<4y_VCiqr3Ii-kN5%rK6sPDqS5@VgtR)F@-@8;F!mUyd!y-plhr8bNUPfoU}j>_m(k0wj&kZ} zJ1;DsCiy2LaBI_tX1cVV*d@cov-qRXw#5?-Q58>4Z@ZrSODFFeQFa--Gr5#gZ7FUi zkYg)gs(v38A<>@S|6I)Zj%|K$xFa=Q~zdd$wc|fEF*rfZO2!~ZClc`1vni(@u zy@TF=0L;J`I%vt#aAcpwiJ7V?B%p)w(7YbNSLImke(!rNKp#~E&0m7L_s zGgG`3%`nR{zTj6WLYe6Th(4bky5_T!rJn_vv?8fXd$Shn6O}EOE=&w4Pq@ z5FcO^zjWGnX|GszNiVsr%G@;?#!W+$HyPnto(CAC z47nlqJN!yhBw#k@8!*H@uqaL8lYC^fo~1K7M`z!>7Emva3Av54F73Bebp6!qSfsd7gbg|1aswN_ zN=Vx40s4A&c!CaBjuSDcgo{~d$sHbl-JV~^Md0-Rx~obW7VlsN4QK|mKVr!3TxF#y z5P-T;XAU_z4cgtAi;5Kh!P@T3$UX*drt00Safw__DXG_o;zCiPI~2b^K!Q&3;0e;uOWLdpTq&INLN9;D?*78GLD5Md&WWJ?rU zwurY@X<*C0Au&e_%F{BH6jOTe?ZPt& zzTIXY0gRhXHLhUBOS-Ss)qbo|>I-8;cuD0U*jkhIyE4uf?3xKLpBmp$S+ zRA}+#->OXYv+H4EV`YjNK@PA7H;niY&=++?7A|Ux5y`^2Lo8<$U2VUrkxW~JQHBSqqZ3-icL_0{Esj8s@1_}&B*drPfQ zGR>wse<%v)0TZWHzASwPqxpJJV=Udag)@Nro;@&t+m=@+8no z|9aR?49aGS1WijZfwuB^L{h>NSu4z>aYaHx0k3G5UlWfiZy;{`O7pDof9E{eFm<-U z#&FiTfhTj`T*=yzc0;2C^x#rpC4cWu_gwUHh>A}|A-T-VG!eRNS<<89HeZSMk85zv zvFi}hWJ=M^z$m*Kv-@}r zk@dR`N%rXrq`8@)7;SPff0_Bzx^l#2D9E+Gkcm>FobWJDp9G@0iG757k5o@*<`yOw zB4@QRYV#Zv5+Y+bWDf6#ZNWy(b~~9O_Dm8R4m}s_U63|^z!o+8Ytaq*Iw=E9?|5@m zwQJ+4eb}O{JlI!8-t!-?8751 z2*P(Ccn}Vy-?+H%{x$Ftn1W;L(!_mHWT{2pZd)A(N{{o7gW##x9T>&V%nj=%CGD>* z{jSyqN%eJ_AX227f17m7ZlDt-W_?so8!XMS;P1gx(4b-^DQ9q9@*?ldG#>-f&QWepP{KCs4xxl56O5zSa7s5gf=WD0a5p2WiVsa~y}kU8VR$95jG^NMz~%wQaS%W_-0@j0*!c`SfyIx{c>GXN8%vTbeel$oTonuyq)WVa-#Xzo3Jj1hXN?hq8| zO~!>rq(N^0YXENm>iW_!OqN4eH;w0!8qZ3nema(7Tx9%mM~i(irjV74dg@2qtSI7l z%b`{0e~xPDik-yudmOM=>O54Qb zoAzBs(gzH;iuY7C_vYhmxFL(QE998kj}k6Q$e8%(-i^~Y_dNN^CfpU$ovg=7_b_et zjIH-pe5hYh&;ufiyGDJ@kYiKb${lFsJL4u^&@o{#M7G$}h#)*P@7SG&^-v2pMO!P7 ze+R+9zys2*ExSTnE5A=lxmuF>!`5@KFj;VjxUqgG7l>Vt$UnRb$wjIGZ`rOVuFoe|c>e zcslI1!Cv&0xgwChiEQjQXOzxy{B)}wzcr*$abXZLU$uEl$^to%erF%_9PQA3@WJVk zaCJ~svDh|v{WSbY%W4~ZClqAOc+WO?{d+ae2r!!k+~82pT)RXK(#CEiaD^f4?)@+^ zw!wAYI}mBZHhAoPdto%&vA;Gre`*e|4X%tZP0z?{Xi1pa>&Vz0o5w+g4_**GukJK+ z(B1+cy<==R{nVA>q{esNQ6%k{KIxsP0+g_Drz3C_vk;cAO3cZKy$5Ue4%*sRI!9gP zb8w24hlaC~@zg>`{Qesm*;_HWas_^c5R{dLzaBmlPN9#TpMYHg9d01Uf7#3OwyNiw zm15RouERIV=~|4SNY7eRk!WKe+!rN87VoLvJoR4-Vi42Lv@N9eNggzN)RW+vvy1a6 zKL_VBwy6Y1atTpJEYCQ`9p1z4DGzg1!$f0A3KsK_xnYaX7K%RSb8C zx^32+3?(t12-M0o=fyGg-2SM zoK_G}??|=>hqJR6@?mdw7O3bf_I(}rYM6!rFZfCAW9!=SKEXm3M4!MA>>7>nS3Q|V zgzhyrnul<`0XyGI4mN2j0G&V+lo|qKCRn@tfeAU;rU>oGB~)aq z=uj^mG{9X~U`H6Jk;Fz_d5Bp4Sz#&g(qFz&j+iRI#S&1bKrICr1ZEu3<|H%>V5Uw& z!Dm#eyMBmZP3s{Je_Oc7cRxY?N5G;(Wmax8ntl-m3fw{Qw5r6wes&ZP@F6;Cr< z?+Vf2v$#C&Shn><=>3QQrZcn~@@=pB0W0PUIB{@nHsc>)e|tx<8AL*pwsd6-l4m5; z!&6mykoFTrn8SDxHQ%1NUH^H%Y{Va5!z`Ipk`3_>c9QeIs+|BuPw@d727G27_n%|? zw_f->5t5C{f?}6n$fL1|3sxZ=JoSC3C1`d9z-V(h4LIAZmmGH9jpfC0pI?eBq9mfg ztRlf_Jmtupe-_5ZEh(5%kE8j(Q?Nk}ThC%si0G+B*mZPGBky2)O!Aqdi^b*Ii+xZL zvwXWvmi3MEhN=Z+h7FWYhL0JT-E+zcKAbpALHe{1K8TW>i&2&$SAzswPV5mnje zAX%Na3HDK`et3vSBn!DF;y@~!&uY@8Q==4OEQpk=FU87=($69Qtar1Mgc_)7&{%mB zJo%exkUo!1Sp%q~EL&W%Fk^o(U=|pU?gx=)+yn%T$m)hpPsyCGO1qK;ZPcLN5cy3$OS6mKx_O>XaQU=&>{RgG6W^g6}gWId}f^m_E{_*_!upReg!hCjqZSEJ{Sk=fx-C zpp0OeA>LayU4rme`gf86&P5<#c5WO)n7XY)2du&2q5kxQ`J zB<_MMoxy5vLAhZ}L9csIooi5t;QQbM$IUlgs`HFrg#nw$n5Nd!j6YMFe14NUseX~q z98475Gr_&-(Sz0_%cLe}JWvhM6gFdGCXQ2W(MR&wVYU)+BFvmwpw(BtUQS9)P+< z0Qjv}>@o-VYOXfg>@bPeFo~JlvXpd7=Bv(k%SZ}r7aFr}vpj^yBlP01JMy?c71sQb zL5@*0A+}o&R}NrssRW(k4Dm10j6dQzL$Ksr0EO;fq zAFz4l!vs{Bo9{43KLyC6I@md{*l zplE4-7lkoTrXl#kK-Y>OF0E+@7M^A~TMA?SZ4*r{SqWlgxEQu^Y{bMlqzphNt*q88 zfRmqT*~*+cF?%2-NtyZ}A8@6C_PH-1Q*c9uAXTjWUGeVzQ5)gCQ+TnU@*{_x`Mxw@ zKNt`8I*RMZYIaFSR#4PkhFcXyfBz@)uBEDW)a}Shj|)jY86BRS^RPjhqRAN{rS@>0 zh@aE?2=Mz_^XDd|NV|o}7*nkL1x*9~&}Llpkm>_kz(h%sx`PvD=~h_2J--Y@xsCTHz6xWynaJ)GTqLUqW33Vk9sKQ@VoA?=>Y$(%9EHFs9;R_|~>x z@6Ke3uAH~FPye}F+xAX8e+oR%m58`mRY!rZgs&tNMCUbHb`R1#s$bYBa3@@i0w<&A zIr#rnh;OydDDYZC%P8;=_Qoi1k%|MOz&V#*6!@DkE(*LFBbF8g9{s?R{>Uy7lNYjf zXX4CT&%o>HF?g^Qm+iZrM-LF)XXxbUar)%$dXDAI1Mzf0{>cJ3e|-Rz;kN5pWxRGh zEm%X38tKnFch|PwVqEp{oe}BK@fl@MKv}q^Ta6REg?Fhb#5cSe>)8^fJk@n7x3XYy4>o#S?kB*yL44-qGFq$)xM5P z5bj(q3q6ut>i}(QSb02beN46NZ|3u$imV~!fPIy<9IYHje->3%4U6eyC<}L)XQk|q z6=cE)oLn&gS+gpO5(Mt%cOgf+nG3muOAVaqbh|efQBQ`wib&TleVYB-&X2^1w(SI= zh85maabVvnz^J{wSBP@RCgVPWO5ne6$AxoL(c1RJG%BtB0S5zjb2)$nDQNy~&KY9V zG@PP?os>}_e`)7{=0uNU#u9C$%5a$V<_EgxRHT3cqwfuo1nGix@L@a{TNIwjWsPfk zTg4RlGzP%OHvtDzHq9`_glKUI)BLTK$(k2|VN689ZoPWu67Z!2B_{THy~<0#uiS5S z25+wyd(? z<)`}KBA<}BueCR^tg~2k6)sL@s)k1}X(-grq}c27;yq0FTj#s0wCP$jJ8mQdzM!Rr zT2aiiE5&z}*lwvlkts(=vl9q|hX5E4^xOg6e+x3ECl3(JvQ%RtB>X)5Lt~bB{rwW; z#N^~n4515nZ58WD*;&%fSzROK#RaIbxJl#-m}9S{Zl=?WHi}1VZ)w#SoURL3bIL(%YPq>%q%}A|cLHM0Jke#|GNxVpqCyYbK38F;jB)GETj@vkV+uG9!lUa3IA7}fAXEf#KBnd59GNef}O1BYNeHo|pZ{8q`c z$op7bVr1Y3o`wfKeFT?&6PX$?5sGD*f1{k@&q*C+EtSwnPmu405xJ(r>cM|F7H}te z>B%rvzy|Jj2Z`skNro5hJ~#akL%Htnxbvk(Ao2fwR-N;z6tLiIf_JLBh9GJ@1vAHB zW0aiha!3Yh3@Hodw+yK(d4z&35?8BDTbIiN_e}a8P>{t?HF2?C8TCOmG4D+>r2{3+3hHQth z#va`(#g)@1-^m_PQ93kcx8TTf(wW~6lVtjWg`DbAaVky*_?KKvL1 zDysZAx?N-mEh9Qx%i9HVf6X8&E|6V3;1);|f(rW^cbjySFfe-Wp7DTw!UgJG(cDEX z;Fy`qDhnVn3+|))fMf`nFmv?CF*Gc`?7~(|?8>+S6r1@#MO`yHx#q)oK_O1`j$RFw zgYdCThiQX9Q(pX~*Q#KFF@rX?gM8ifObnd6u>se<(q%O>gmIivTw( z;q8xLS%|5!KY29N+Vk0RtPu_OF{TB-&Yg8Uvhar?NQhd4#D>wgTmH$%^4&8u|3FUb zR`_8kDX^iJwm|ej8DIuyJLv;lI7%)<;N1sC@FAh?fqSK%pI~t-AZ@|-+IF0oDql$T z%yi_1VFTTrJlntte`)KOjZMAc1usDZfD|NRL=Il8>1Troacn#t=eZ7>^1*g(5V;-1 z1e7=9Aku(niPMkUM7ePC3O9eitj7LrX~s?3+nTT-eycA*)sL+t@4a}{r7#Q2{k4j2 zEZ9pzY=I?2$kBN)3&|sS3A@d>1A0VEgTvuay_Lhl(uAoke@+rJV^)GJw0me~fxHs- zCrZ}+L#+O1C7kaZIwyy3@eD^I;!htUNJPAbn>K^+%^A*x-Tt`KLaTz)N3c~AHKI$R zRI$E`BLJK2ukB=QmP=UnpoPpTbjHL0NJIe0+FQWO4>)!n%Cg!R8p7m;0XgbLcBn&& zc~o`*Z2_EQe=`t;*_v3>iyjgrzN51vUQZ?zJ%m>eWV5a9JEqk z@zD8fM{((c$3-|IwGbb+R`ot{Lvum3K*!i#pc^*4UrWtpS`Qr5Qi&rVZP~{KfpTi3 z0a+hA(&BnQI z#zmn=WO{3R1d~K)h#2m2$jTtdfZI3|HW=K|3DbpJBNnJlxbU<+8_M*Ir^RXmbuV=w zsDz9Ac=qai==pFoOOC@Otj6TDu>ewo+A@;c<*}lWhH-Hcj9Q4!Bey@ZfI!EQW4&)+ zvAVP)f9P6+*q#_cV}CN9_Z)BJy48yi)~C`y+p=VhI$2FJf2%9~cbQ$FH%ks%f&!#O z6&o9#knSw}5XKATUw6M?>;EvKN&EBXK_bH*#s4L(J`a6A*XkH}t{WM`?vUlqw?ta{ zEPO#a{bax(RL87eDpT;q^C)yOdCkm1Wxe>#f4+JpEChA?;k-Y*@K(Z3t*WO=AAVe z64Lf4A@g=X$4d9X`V;XT@xLRHd4S{EM6gDuEaC?vFIOAhn9i<6|0Ic zf3-9Ad|`z|t!h<|wlxUK_Ef7+tFX5;Ykf3N(ruk~6BHHfm{DpT>R8bUb{U~Yyf08m#$V^e2w@xeWZ-diQ$S#--z+O+8TNDra$YVi|FBtU=vq?+MQJ99w+qqP$#SU-uYbZCa#Rf8r;Q zVZ|dB8(IOFc+bHEh;>+Q^dSy~=~u)L2}aGxnduZb*V>gy|H=ugiRrY3LcHz0>eJje z=F|i$iee7&kbVP5aK51SzmN=%Y;H7H$aj#(G2tXmsnx6GaH?k~zY*yQc9Y#;15l{eiw_v z_4XUNA%{aY=POOg-1=2AvYZ+ShNP6w)n3l19w<)7flBd$1S~xZr;Agpe-=bE{RDgp z%|Mn18f`}|AQ?On+W~5t_?`{hevruN{*acInoTtQmk<0uklQ{q1+=_Ur0tcG^@+i^ zeLXNVpP_AFlYydxl04GD4)fSXt;5qI*kqvOe zjLu2t=Sv03Sqo*6PxFX|b0#SCgOd;u#C-2(0;cm5CrbZ&qfy=ImY6&B=Qf`uOW)nq&^?IBE_p%1eNIXPi9)WvTe93f1H9A=dYbX5e-zDHM}AeeMU9;Z-kTD(@+ci8 zrV7rYW&YB^&VmjNUT~JPIuH#A-kW7eeQC@G+JH88xHD}KcjC7V!OwqRR_B~}MQhe> zXb=#trM?@r7R<;uf4Hs0zlvw?!YAtD&TiR2I}Eo~Q5KQ~QK)6D+jqbMG312|O$8^d z6+RgA-1i6&Nn4==t77HccE zGmdTNNJ<9`*B6Y(V}cKy(~5ki7D$@Y2>zg!jttge--H2WYWb~Vl$G!BiXIuL{)Nb z3enK}b+Gh04qQRr7Ip!<2Ug^8HNjb69GK1jT{9_Ye^sAp{}mPllCx^%Jhow%&j98~ zZ5{%Qq!jWULU?}h@dh)sV%Z0?LhXTZ!LLMQpEe+<3jJ1ml}G!#OL)MfwkOXuOdqAw z(kD#Hf46;?a@dT;`&D|~UXt<_o?1MRhB6fr4gqG!gS2E2^zc_QFrJ;A{3M_Di`eXQ zMF5d&tg}`lB4zTnb|?^lhX2fp$+8%SfhzH6ync5&UMI~Am7*rFDw)Gs8MomayG!BU zoDI?dnmL)vYw|I({C0gv@SM_9$tRa`U4~c(e~}RUQ8=8=omRlS0!;(^p-BeY5)#Jb zp6$OKFZrK1c`#Bs&hk)aa;^0Ai+fjb4c%hnbXku2nyE*+^SlajmMd+jg;jF$Skk-; z+F0@iXy$K@344aC*Q;E}S0U!FoA%!!`DC&cv9E!^! z4dYvTIX#4k08Y6P`G;d~Bt_!d0?II#$|aj_Hb^hMHUefxRKc7(@;^_KL3JaFDOU#C z)~7=A+ya{wBnzt?f3fBe{}t2E#&KlZe|1>%ut=HP+(!+);ujN&IXp-!8N06S-+CL@F#C*9Bf5gsG z&y2|ow=bU#kuUpOVjJ!uUzNBGnk{;HF*ISG48SOwIKBg)smcAj?unOkAzwlB6YGr? zhIsMIay%o?zswBbPdDhoi9C_6my zW<;t5j*Vsd`iSn`{Z!N<-xr0(BiU>THuMM)J%^e}5pKN-N`MsNEF5}Gf6k=#KG-e8 zj9PZJIjF^zR?9%FMrY{jK7<-!!^Kx*`0<@MfIw|iZ1G;0pX+rS)O5_oV~yScV!;v0 zd4AAA+8Y46T@1MZhG1SI5Ru!%=qSuXTt&E)cx7WEJnc9w|;@N22UL_`1p0E1Hi zRRB={9ZlBc`Cm>1-i4(Zf2pH3ZQme{*PW{c@1#}ZOerl|6ya{%zy3Q~ThUJn!2mw` zYGot*2PFbBuj>aG5e9Oeyr&F>S2N$ zc(Fj(xG(xN@RKX@QPHm_Q_z^7F^h& z;k~^;)i^KD=2SXqbNT5K$A$dF) zkA;GfF-1jv2P0shAc4pf446SdkPr|A1c5++5d=XHf`ms9e*{5Dgh3f55~ogUH|!kT zIV>v&YH5JFz!D?BG(ZgEIOzDWNXVQ^RjdJ=3W6sGOb(Vb@OY;!h2y!t860B3buDnA z>gr1fvuu2v1G=c8d=Ou3JyX)9tsBZWV{LKIiCmzK@}*E<@X$-SWSW)1!#AdUDQKg)U$*)!c-<<#xEHYg5T?}$8@R7 z`{blR{l+YJ4LR^2Gid;btYTfve;uOI3)VM(3}aT{i$m_SXay8WFpi?rE^y#8TF<5$ zzZ|8@e|YSnPpozLWe(uq_0m9fPBt$uN>^4$X0Fv=K#dwmycOzpw1}=JSA*pXpq+dV zM8gzbguxM0D2C};W4KgWG(xNZw~_@Cx^{)r2j>(N7Z$YFTco&8Rq#hU!n&nPcv^IM z5V$?VJAE7A*ZDKoM!WT!vk9v#E!~ORD21+|e+mZ)Wo{)UT?SppM;RJqF#x$nH7{pG zIs80ATX{0k$@o^p&}dyZt7v49buDh%iz-TBkE0+`JOusP0*9kJ2_V5cGMIx7Y>9U_ zLTuZ=5ET=8YJ+L!clxbI>kQt&pfecxq`_uo1raR}*0DaoS^RL+VznfSI~YG!Q2Qtf zf39Ymyg?(d3#n>D=vrX@eHc;5HDVQhjEe^h&sSf3!e&pmDp+Kzg2mNSsBdX$Y)7ck zCw$D`~(V`YJ_tO!!kfGy2!TQ}d#K%xe{a za~Cm%DYa3e&s`uXu75EMSAyM%U;Qh%e`|u%vEUVex?#P+B?f*V2y%slj_NRW`LI?) zn4F*{1R`=)h{16V$=9W<3vGmr_>HcH&qoBkcfvFqqhx1ujaT=kShJC z+yv^}AY@@?X(Np`5B0QN$*vHm+WJOtr-H0|>wgNMG$av~$0wj)2HNZN%^-TB9W%6P zypZcksBZN3naxDSy3siTZ$1rgEe{ql#_+Py#Sxc{e*4LzN~QchQW-fhf2%}R%Tt>| zGzJjGQ#z+#rf&|sZwRDXai-(`X4(P)ZX5|54Rm@7-}&j_y{M!uFz#b*P1iR0t|TEx zcp;s0h$_{e(Q$QH$X2QUjd*}->CtGxdD9X_4r|se{h?ftK5WXZnb^|Ld&bknmVS=} zP{z$cyP!ESdG-nUNrG2Se}S$*NoWOY5-zsZZw9anS<8Hr9M@KFydCxkUmkYK>C;?GZejT`OqxwdupuQD>!l_z1yf5J3}mrFb1H*0I> z%L?+0o`NZ7c?_hSn@A`0X7q$u2wybU8^j9I1F#Fyq~Mk@InD4AeAnGAHf4F8a<;JK4oKP&g@;N&^1D*)L2COh~_Txu(J6iiF-dCGbXZwq? z7V_P9kP8MsbN3nV7pqM$s`*Uhf`nSU^#(ihTk>f8;hj*G{8>eC+QLHnju5 zH(U`A{vAI0%>W9_Jv#t4a?D(T@vP})u?qY5?&YZiiXdKIKJCmf%CG~Cjwl_zwqF&| z+oVevoI!%?5KV;zF^ zGy;#?6tb)5f6XtI!c-bYNEqH@1k(hmoOnZ+CgBOlHsFmAl?bnd;!wroBZt<8iBa#> zPUSzvjxjx8SL%T>8ScC+GBJpXz%T-2$sO&CjBK+k+z)JOl37M8-kOHq@4G5)CRn3M z8W-o-LA9X}MTtu{Zkd%m%i;^<%K#}Fx|_Q5?VcDFe}1VsDpLq(c&t7|4olI2kxWqLr zCxA;Sf1F0M>c4gV|H+k3LKya30|%vduG&OI)B|lH&L(f8@INokdRJ?gBPGA7hxXhgWd zMQ}dj$|nQ*iFhtZURi;z$-Kov<=YlD&|E*>f4XYGeRU0|xD49DTa00pM~`oD!_S1} z)3hKYgjaxGDvq`b9>azfh5*%j%sCL8Fr{VAUJM}LZ6*>6oJ(plqAaT`JM zZzVcE{lj946W80i1M4HwWr0AM_zbUg_?LD4~C&c~TB=krH) ze_c2MX;M7{A*eyI`Mz*v#gL}`(F<9l{W9M`n57KK=exGxRg)Br#1c^LEueZ>Z><&6EEgY}41nTN?)bLYdo(%p(c?T+`fIe|SA? z_}**8YhN_4Kg3!(tmKtO_0+o<<8mp~Q~yuC^_|nbsgWs6BXN7{C-Qs}AGWb1QiS!a ziHUoP5-_+~d`e@CSBB&iMBfC4ZP0^NeQ5$2hdfeETF&oX62M9?n$QG;#`e9FpHbeR zZ#418_Iuwj<9G&#e>(cPx*$kTE)Kr7#AJ==(VDvXp!qVwlQ zs&STa>PvRQXE61MQsHt6)|%8`X_238_ecJ|T$!;=paW=2vA=%)4fgZ)OvWr5GMI(N zB;a>JhLUb@ll#G%b}!wo}JomuF|QjwVB(5T7^jT|z_wqzMq5fA78e%9kGm zddXiS``&cT1wIDSj__{1F(m8CznRX_(cRD+2D#uBuVo_G3tnD=}z>W55JY$6xC~LJ@Q4W}yYnIS%_BwC(@NGUBY-9GQDM zT?CI-jzj<|y`Gv7co=Q+e<1=4FT$zUx|*iNY7R zB()Z(v;6}oOG5n|AX#D5VmY%6 zz1$=!Dyq|FqOZvADBiEmd=8h90kHXI;7D{YjeX#T7KHq|QsPpsf9yny%reQow7))xA1Obby(D8wl=uvfZRbZLnctc z{0=YqI`pmBQGUo+mE~$2355?2yz1_m ziDS`2XC*=z$5(Pq$d^}c1TY@dhzns}MVgnSaX2nyf1e&OIwJXO_x}6G-bgTq&lJEA zr(&B8Le%nU7(2mTrsuGgbGyX|kY*P@T5# z3n#a6TL;Q`Zu4AhPSe5eOw#^~*OR}LG8{US4Rp3xv^HftGNm^Ux0D_<6@O{Y;ZY*x5IEBg$}qB?Z5jtVkD|Us&IgITkP0p zEH4P9GBljvZF|}MQ7A zZ&O^&iF62R)`y9p0$kVBIAMViG;z5sTUc%;0y0SjyTJu3{~ zkjt~Aq>{!IrItAV)+w=}_SGh!!%)nL!f_;UV}SU{gE^3=O}+JukUiqBxStcEn32I& ze}Sf^3FTI z+xEB^=7ubD_We907@&RqSW{<2?X?~-JPy?8d}Kj*kOP61_%m*;)epY5SbLe^dI!+@YZfBuSY z{Q6Z(M6skjs1FFx=s4>?$pP235Vm>)g1Cf`5E~HrxqtspeRtgI!p1IL+frI?iHi)P z5!V)tkO!j?S+jX50uWX-|62`ak3g;x-Jr#|NMrTz6a~LX{I#0FGvox@m(SHFRpw6; ztof;|)KbIZ6D1)ul2tE-Q1ande-a2@Hi>gZzE)G=`;+U@JViP|HBH?1FRTLkS@Dre z58{}>zqT#l^EYAk#2zQuylfI!*L;f;h3RFs8pKGi6^=YG?uDR1d%Dmsf48B0^0e0X zp_}|h!7w(|>)=gL9pSbs-dp=pJWfjmV>H6!bbn!8Z*A3tk&G&iQD`5>hvnzXw1}V4 z!6$Kp>cZ+$d;eQ~Smeg_(1;P@;A?+z2f?^n99jS%^3FKG0%Dz>bQ=CJ03oJ$i54ct zfNQO9>X`@8i+_+P@MhRbe~BRmrE^N;XAy+jO>8p%Y_A(ka537s2;)ZsyCJ;-RcC5b#K*OE zfZlMX@ewGRxCuxRe@tB6!w0dv@TsS5(b26TF;iQH7Cvg!pb1FMZADh8KEKld1N<@= zi(ro?f`yWq5aJmw?a*aHRAIrOb}eTHwVmY8IAl;;VN!$Im<(`G`{H8v+{X37;ELj?3EJQV-h zcxmGSzJXNiQ*0cp7wUi1{m2=ICp8ho(O>>;ww)_3=e$X3o|U7avuKU7M{f4HEua?ROUUW*CD;09s&veV#l zHRv;dAcKV*g!&tnf~py`ZE$=U)emZ%B0=v@9!b6FH}14jZEaM+F>dbm zI!u~>|H}VygKM2PrS!2qDofNKF5P}C7cqjz@Zcw|mkeW$nnF1(rGqvV1He}bp^6WU z%Bv1#fA3$|N>K2sEhQtl=Px;0nBG7G-i)5Ak>~S?SDGd_gA40LsWihZXg9LyE(1n4 z?8AKC6ZfW#h=p(X$M3T7=nxtoY(9(7*HG1d8x#MygZkqJo0Q7$K7^B^PD`t3`O~ke-+e@fQ}lGb_C2$T|yF_$P#BqKOGg|o)5r^fgA^;%YZHfR}vM0AG~4LB*VS|nGnF(hgV4LCr2Gzhe zPbRH+v-a@)Qa(4*JBGOyrz2_E#+h92I)dvm%ny?);ilS~$TNw)D?VRWd^*Y$S z$dthnnP727mCDOL7>XHQzKGH!gM37vC`r;Dm>Zji`Z%Q^HucHl%@a0+6yKQPzyKL91MVO)v3@-OH$sr|9AFI#vO8i9vfjTA zRs#PF)`;`jRdMuRABI>I=AZ#X*$!&2c?kiDO??e|x6+&S0C~=~SG5J>e~cL-XiPvh zg}weomOxcbPD@2=qn)SBL&?mS2Af}DC{UriU~XNyzG5q)y6n3cauJ0$+^C-j7pgh( z)R`X%?XJlLc9huNbFV_Q{|pl(?QrNH@zJ&hf`4JqiyPY}C6=TU5-_Ss$Bb`l@xQ?w zoe9f!`NiO+1aE|Xyt7%bf6VEU4#l;mx1EQ%7JQMVcF;C5rQGX+`7L|@Qw)7b+DW?^ z!kKJ(!>fyyeRI|S5q!cg%nlsXKl7=P&;2d{n7M?;TL1XquwY2>1NR5O0qN_N@-<)+ zrqF(#@6#x)=HW_+9jIt3e39xFm1tfF;GES3kwswE*sjM~ZjFNTAYIO${v^&^zc06r(>nq6m zqCnAIcYtBkrVZ|gLI0W-U;puMTi@S!8FO&6C3BeF_`?H*F}!0=i_!sa<{@(2v>7l~ zVWrBtyDSmnJc+OrZr*T6F+U&_ovTsX4dSMyGl@dbSc_o4xZ*!-Mk0QFx=gi3!Y zU#39fASO10HaT!43ScdZvc{@Si2y_wtw%c)+;av`jL9Gyr1WUvlv)DP>Jap}Sn8is z^_04E_;*zT4gO_?wxb$HZAzelPiq%4*Vi=X7M)jGO`SF}lHMZ7o=4y>Fk2_A@dlgd z3a=Yo-T@jNh9WeXl=HS|swcvn4M%?-clra%Tb9f8m4Ax&z|_&GLZ~1ON%}@N)S6y<(=LCeP-<}b zrEti=tazpo>@%;*h-J&8qjednx!ie?xR=4!y>C|KsK&SDkN>6*jj%_90$Y*LS*M%i z0=DsStI~raZJsV5l8=qrPY7g}#zde;0km0kcE+b0s&0q8BpCx#%kLObrk`E(Mlon0 zQY#*Ps(J>3ow*@NN*!dab)SEbNNVCRvMg)?ql0c2gw#cEMHE~u0BQHu&7Etp=b&yK zu!JWan&|%S2^!xgxMAc>)T+IU4Zc|+%|ur+=1g2N?xR2pca_v+^YOU#(Mwo^;YF|@;;L}O7auSkf-0`R zf*b4z+M7T+uum3$@AZGk9@1Ct^?16RQ$6H*s%SIOHGae;faD-xu;fWYA2MjbL-A18}#E6sr0T)&na-EQAoF>Nx&!k32;1h^_KY%w8h2*3uO>=76di#a0x*-F> zoEk}Q#hLGqA4FI%RfrxeEp<@*qamk!h2+bl$*k)+O0?^O1#5)bOyne%;8Eb=A|{2u z0p*QLeOrGI6fc6KKGmLsBDtlzR4?B1)CuOs^<4zaM*my6>MVkf;rJyg1eTXf-#@B0 zgcc+{E(Ak4*Kbp4C&sO}fMZB;IWLP-*MFNJ?s~ce z!Z*#WReZO0zI0cOEHe&yW{y)AmdH6IboQiz8&`ks2_aeZNn&EKss+#qnC5bbsJ;}v zOQk_eogG~KHssI__~pj1#U8d@3pdfW3|&;F5zUiczn5F&A{FVUu>|X%pg14I9M)LB zxmfMXn}Gi8ph}1$5a@!4@W)McbMf#CVTj|?kxG8q&s{8_xutSE#?jmYS>ZJa<#BJY zirRm($iJLl76M62R>jD@yUbX4|AdHe6wl8QHyI7CC~gULxQ_=Q_uev=zJ)wYUb5G= zXKGe}@OUB^-nJAvM!#@N^}U1&>%6Pti8!^Ow*tvo5WgtU$nZz!BT%xv+8~cfAoBJD z0y#VtR>d+6K&;l##ADnovYgXi@W-p!&Rc(2W1?bfnsW0&DW#HT0~`Dl#|_ly7-8P$ z9MhT<0YYNkLl#j68V_N>IyPC+RbVCmA&UgZ9*#B&(kDNXYWu?l8;cb77Qo&+B)aTR z#B*L(()N0WOiF5w6wQnCmw(m><*RNavxH+}P4(4^lPBr^t|I&pWIP3TixcKoPJMq{ zpTr(h3F3mK&o}fMd0Bfv+mG2eS<`wXjEExVw7kp%ELy1@B}NM7>nfGuM0tGQ75X>$ z3o!Gmg4M$~>~UD>HMUg4r32vs^)Sk5w}~pYHjAOQP5NqOMHBNxk7(q4)BxZV~&`ns-!-|wN||BA9V%LT@HTwo|s0eiaq?bSUeX0|FDqVH`_76_}#t6jzM`Gu?aqD5Uy zQcx3&;KyaQwX_@h86l4mQVK5=ZE2Ze&)V<_Eh7F>N$Jpvl}DZcaM}}mZc=}ZI*NUM z_>Qm+kXbD(EKG$tCsjxGV@Sfce0jjAh(v~N*Q9HfSxLDj zcTpM=e&d56hWi*XvN7ylUYx!gY7S&_(HNz}>`1YejeOxDk70Xzf-l!YZ9*oh8E{R6 zlx`D-!(uZmd}L1cr;QasvXXxSCTVzVO`6=jL~EC|StZFUG9TnvzFk2tJ>g9@xU_z(W2lj=GD0a)LaCo3RUwGP9+pXLfAMa;*N+#t&Uo8 zpp1{EsKL5_e$D9muYN6NDh9C?99RGAsIcv$2Chp*H~P#hPPTBDbzOhC?7<%*50qG8 zvNYv_CML;IUMg#C@gNo3?z8!zCz-_oYGiPNr_~HSP8|}}hI$_9Tot{hJ8O>w#yu(7 zFnB(VqvRgowzSGlKq2y&Fp^pMzN|DsVRKWE2~}H7o*!YxVZJc36y0%AvDI(IY*KKB zMWq)|g_bLZ4hp?VBX@tJ0}$-B#Q1+Uz#4wYBO}>~O6-6TmM_QSpT6!Jw)pP0Z+$@bVPs5>2Dmm=vZ9{+$$NKWPX!W( z^z#rd;j(n?S~n2SyJo$v%^m&NrcH#>&PUFasqAUK)BhvY&;fsCYkjcmNF2PYvChi? z>0E6=JalCOcsT$Ud=YjtyDWwqg!4=u==eyztG8kAw{G66;XKFLL>mXl>ov=Neu?>e zoa&1zesRA0+^d>cHzDN=tn4+RU-{W0`tx!G{+deMa>V1Eg8@3C2)irrIY`0|5dy^D}hbV4`AC$K0F==>zM_s%soG={vuDXlu z;ge1X5wW`1stWeLZN3S~;|!BR24eJaI%Iv-Grn~T*1##zADMR|!hUwx2dnA%mtNuM zKv0gI4-vG2f6_iil$+`w$`AOHr+Gqy9$O=0Hbep^*6n{_&!Y=JFXH9Ju65^=lKV4sLXotBeLp2ZyztS}rWjNj z@;{*IHY*)L!K@0e^LlpJo%e&fj6F7=Q*@l_VDn#4)n`P5>7x&0Le( zd+4k!Y8~ng<9vEQj~|i^pJsB_L-6lZ#4|9+R)l{}FT?VajsF$Y`Xr)%cZtRe->X6A z9pOQkl}f^c6Xk~VVaw=ZTLkG@Gwcr6AHnyi5GK9zZIxV-2;-&EQSP>@a?wSO7+LOV z*?ysyo;zU^Qi#fUd|NoWLjo7&%%G%e`<&DXB+JzePL=cNkGl!49C*YMGX<3{07}a` z?x}x$+_@>NcAugL028MYNQL2(#WnPz6=E)mlpf`|L&#epVOd$zO({khf~ypgqwjFX zd(p8=DPrkKZ{;XdpXK(5jn}2MgAPd|BV}fBZQI#U=1mFgQ#;74PIE!8+<(fuY!(6< z8~-15`88#?qY|OVA`Hfh(3c&Ovva)0db59u>@-Fw*LWBz1}IKZrZqMm3z*q!*e&NH=rx46L5{3m=O9P!>yWYnQ=9tT0Cw_LQ+*8KX4g& zS^1RtW3UAmLs{2CjTZ)5Ay-wsp<;2n7+&qbx_lnGLAyH2eKt*=cdm<`zfCLLo7R7E zA8^t;FWmik2~urVyy$4cAhi@ga5NAiY)#tY(uEeDJI0LDW(qQ=|NJ?xckk><$rPNn z>2LVg)cwZOoHS?B{}6-`M_u$vzN(8p81;9h=@lm+GD@o&1M(zQI<;_TnZpq@ETWn1$xMHPf9lm3hp{Y7`_2WCoUx9X`Nms>Vy7Q(3Ed{b z272yU%|Fq>5m*B3SpYCsZ*_D5DW|h#h_;}I1B`X!oU)S;t(<;s@WLL8xO+g;tl8VDbl2FzaNHhCozA8l;egy zd$1Gad8PjTQwquXW=e{0gGJz*bXbNnu zoV3N2Ti23ixEiSezJ+;`E0*7X$+nZMSOAmec|3LkCKo#t0v8S$SXry3G>@K>?ozL8 zj8}~#s?knOSbkTajthKC9&vx42Z5cBRP$@=gs;9-izH4WyWh{GII2P#@DF{z7r=xt zDMke~dVcVj%=3ooagrwPYp$H6HDg(8A?Kg}g~N$uFoj9#9bnz2@L+xJf)rrZDKZ^< z(hG8+7BsTMQUOnel|0;yQrJWp8p3c!B!UioFd&001yNjaz>~8s0`Ip>@03 z&>Ocq!v>dTaA9)AQl5NYA-Of>kB7|QK%k0b6<#Z`aCdU4{w5RqAb0Vqvv2{Itbz>M zkT>l-LSX#lTq>MshjPOrrb@&wTrh;#_%?~cVQg-t^qeL>j06INVI(gCrUk@=uCs@R zbky2Cj2OY5jD&B&G;7y1|DGhBys)XYRV!8vc+o z%D`XLWg33~iIPjsk44D;+! zS7TbQ=OQtA2QwtqPl9!4N1}O6-na+>Z~#&{VhBS1jrE8wrw#5NzpaIJxy*jdT_L6M z!W4tDPu~yV$kodeFE*OzhP{fHg(#CrjixBVEfuChwHsl)?D1tVGzGF6OAdmmZyNEU zL3gTXR1bgEfU4L9T-guRKIWVNjqwvu3cNC$)q9lsPpUJ@P^J|2hlvuKw|9FHeY5Ei zFVD3n$cJdA4*WFJa1GRnmT#Z_$}IfunSN5h+okBRCFoZW9iX7_JS*+7v(OGBCxLlz z$UO^@Tkvg=hbV(c4Dz68;w`T`jYIOAcq*$p#hQPrgRI)DTYCUNiFbwBhV^iEqG3t6 z)TUgQFc6b8Of*nezoZ3$1r-zuXx^oC(C=4F*ubP;yo2yw;Zh5Oo#c1P2!*;x~qm^D!ty z=g#ML^3LT=Ve@4cwcsk>byM@b9u+|sdwkOpLKcw?97Dv05uDIXLrVl+H&51xohOl! zjX_0(@IB+!3$UPVOnHJQn+dtXxl6PLav6UV(!)(rK%L%Zq3lU6ZM)@-fD=>zmpBY8 zs-<-6L$p|er|g?2R}rUb^hCn;ZWysZdiMs|xTZlKBqEU0C;e|KktB4d_K$72oxs|c z2<99JEXVqjLY1Rw#W562z%bkSXJvK>8F7uc9oVyN&zN23^v!udOXDPkBg!qCRM&sL z-;!#G_z5LB*-g@kwiBW4v_mzLxbR%8$6UHBqTEaz=*z$AYO;deB$Ki%$FLYw0lXg= z%~V8-ET~}rAp3kWJyTAI=Cq=d&r{9}LONR@0KI;hWZOk;VVS*j8#NH;YNIKm)D8*R zpVz#mur^n1x#|M%{ya*j!*zTXsH1;ON`pIbmbJV4%!e*~tBSUYPnF&fnR(}KGZItH zw}A#$JfO^iivH1KnX4M@bIQU9oDnyt8;Y=C8stZ$KWE+>6YL^IKT?Uv(Ts)6^ej0d zCpB&XNEPRbeGFt^3XKgY`$NSkSrRt>0G}P9p{j*t#DF`(naBD=s~2GHVyu5r4YQ6S zX&o8%D%WyBQe9C5=f`HKin)Bg{8L~x7J%qgV)qA%Sd_Zm-%Br^62zpWUcg4B4gdt= z6ms1J)c1^D9ywmeg|pbahXrZ_#Q4*#0nw3xug$N#+AaPVz4seu?cIzJM1;T$7?bvT zPOG@B(fdug1Ll9io~^etM%sVK%zfl>iRwr(wEO^L1(Ih5D9JzJ56B#udUln20penI z+JfTfWTmdkKj%=EDrgSRTjfP*eS-7Y!sU+EW^qi6#aA#0pi^z>s(NL%HpP{_dW8Kx z!|W9{$Xnogr9WT3veaV&)s?lHxmGZm!(*Bmi{%iYwIz3F&lfD(TI)gd?v0L_10Y&WP> z>k)0^WmiK)SgipHFX~5}5PDuda_Fg zF6AGP(We;{6HtG^kwI+(FEs9OM8=Zn{Ym=|xxWD^O4qqeD{Twt5#aX^iqs7Z$RhDAZ&SDO{+^FYRhm zfKqiL6%2hzyBtb49i%~ONb+bpLWBOyU}%bHw?47RSuTJ6UyOiO<5WS+g#}qCs&F*M zzHQhRD3%D`fxc)=Nkc}LSo*leLjvh!9fknV6{64=AA9 z<_RP52^NH!(~I;g5q zt4UvvpRA>FDijR)gv%qZ7wF&W7IF~zAkRa)GJ${4oQa6}zM(9sgWiQ1Dq(OWO#S#e zT@WG%eVIxqi8+-XSp_-jaxne`3`2lczTta*`@p^E)(lBfV1J2+1S@>6(}+P*V2o1& zR+i*QgD8z79f5Aw%~X0R;tYXT_(DU}NuGL30S}0khKyWM zU~H3G(3*c(G8hX)N)E+IKO;ckU^qYwg2$jBa0m(lgPlOCYfa|+e#bV4_$LXdNc zHyl0qpcq~ky|Kc;@wX1cm(hLF@l`G39;{XM)<4A+)=N0zTX&jX088fOmm=Fb8&1sz z{8lfpZxZ-fJd6(?q#CFroOeA7Kx*;gz^LQ4QC6Sg>HkeYB@iC1EjL66Ky-;a;)Q<~ ze~hI;JjeL*xED(=b?mx86`?Al%enI)mMs<;TtH!;!f{!}I#qepY~?A}arz1ks}LW% z16R2?-Yzx^qucQ^bY2EOy)Y1Xn<@mW z(Aw8dM6<>sd^!!Y$>xDQMUloK@@jah4c~nSYx$+(5DUp2y;Mj86o_jtLAHN#(@XqM zB%$RjO=#J86k4>+b3$>JWNZjIP(})Dsw?JDsA`G;ta=oU9BX+J3Fmi!YgLP^^9Q5? z>#`%bs+IDPP6J8b!4#Bhj7`_w4{G0%^;D>4%$5dqfO%xvx)%%xavp+8mJg|9imhLu zAYG3we2Z$Pi8|RQ`&q@5e`$Y8(tij0{D}&woYj^nq)7tcvTP3n)~}``wsXKlrWojH zm~10v;Fv6ell06Zx)V~i?DnTdhZE7z1*tbL#dR_3$}Xm)==d5JW%bG^K4Dw!@M(Ia zTYO-qm_+{oP-mJXz@g42HP*nE3lcOzS#SCPPhjBO(2hc=gxQ7|(i(pX2!^bsJ~KIF zM)5OyZCOs9VziJT*31w=RKaUzOeE-U5G`eKfbpdJPSzA)-pfU3f4E^)>k+(eN|8OZ zQ)JLcMP}=VRuvhWkp;dbh%{Ss^g+AHCzuc(X95$BD zVq;2c581e6V`%)TP9a1vAq60ak4pFz6dy*k+&`%}H^w=E(T(k{{BqHz&e&wkCgsU1hA`50A%OL`_y{tr`G{J%k|LszK=ywb6v)cR0$G$;#>Wd} zZ_GfJ;e34F*U}@Vg)s?PBOo_JzL+fAkWHC^%8eP}T%u7z2WLocLiIYtA@lWvhlJuc z)ys1o?ZU1Kab(o++?$hr3L1Sarq3bYIeOpl83+O%m$(u0c&BN$JmOGTo^t~Tw9`2FIo z)vjb&{_Bqyn*Ko2M$f=rk{ib- zG#A5T4-!krYnvQz|DTH3GSfJ=**eBq%xptkfKGa}TK>Y>2CNqZ*oY1R?pW7XTv7GD z)&2-Ucev?5MOweN%QnTyY}d7=pefw1N2waUK_8e>(N#mXJ*OkvwmGM=Hk|D+BeCsP z*276g=7oP>i{kd(5*$+YbT57`MRIY@H}d1Nj55f7BS+sIe?olZeW8VJ3XPH+LP=)Z zshHR6VQR!e$OS7$%RC;ux(Xw+rL4B8-0YHHIDJRtbpENXv;A71rA4C+VframcN2$Q4pihw>1)dGmb{zSTWK!7b<-Ntj;N6+VqKV>E2s#i4d%pz(<@D zIaP5)@v_Rg=82c-~#Ol#LE@WM6?VqD!7D4ujfU0iZf2U z_2;f^${DY~kou;LPfKJ|k*8Hm#dEc_c#79yrr)7t3rtc1v7o3P2?yaM;R1L`xC>by zX%ap#Pr|GHrv0y<8WBZbeB&X%h0xyvT~vKd z8SFU0A`Bl2o>L3{TO&O~w7Th_^eX__XMg`Mc(YoAKbu~LODODGBd+F0EFKN}n@BfL zMZ(&74d-YaJ1?067G3K##Sy;4FP|rg$R#hW1c|3f#dMOqGn6b)^H%KP_*36wG64fnOPC* zcc%37bd^QC>&R%+$r5KlOsLZFa8_~+oQWbt82>?ZgLl&?;~OjOwpv*vHpq;zFw(X8 zalod_j(V}-%T>18egL$`pde&8a1iyY_IoXp+Qg(>alckfynk(Vj2C}S6UP!OoCzQV zA{*xX%Y)>VKJ&QI6>5GX{9kWXWj6Wt-!1^cR!GX+jLJi?e4MSe|1*~SXl4QND!v4; zw{#R}@5J%FWYi~%)nHQr;a2rCs*~E#KpcEk2017Nj4;E=9Dg2hOv1ZFbS4=_IH-Yy z112mI{f0yiWr7V)%btIQIHdIuwh>TZ5@PzA(0g2f$~w*! zno7Ux7X7uriWxH=L+;%C@>LZq6jw2XaucJC$5bPkOMW!4sL${-aHNl;OW|QWonV+%CHCrb$| z|DNUh&^iK&G2&S$@w%Ll#&L~taO+RpGqJ`C@*k!v!rgzMJjI&Xe5C3;!kub_M4$=a z9AF@avndBG(O3VK-^!_U4TvHzgf|pWg)da*5qYX-M>0&7z+I;shFd#L9f|r$E)l$8 z*S-Uz5>X$6Q$)Eih)%|#ZGiMG{ZpbP-5rpA#t16CYD)|iMbLUqWtA%>Re_VhdxXN$ zMl{8&c_V*?0ildfn3xj|eDJ@xb?>|S=MeHcYQ$2r+?SA$)WKC0Vpg;TFf~W{9!EiQ z6Ng2{Dxr_7G#MVQsL@FRn;-+*jQ9xH4%r32^ydBUz?e+f$dG01o>b8issQdsMFmk6 zXVmQd7{O}Ce#B&?$t>59Olzal_e3h4Px_k7^yh!Q#Hqmg$_0UxHEcj15>8s&@d-P3 z)-8EFz(_cItRRmq@(z-TfAw67U(^UnLca@#lqTk{j=31Bf^w&*mwUzlj+BXrgS>&7 zrm0_!#XWs%ctLg51uddw}Xmx10~}vU3s5Lnph{+5OhbuoqW; zdPS#Xh25Xf^L{wv)M+nVHA?+)Pg0;AS*N>q)T>usZ{5Pq50_slwmd?&SL?^uw=T2$ z6O7Yi2!o3K(Jy*XBmwALDQHMnmk;I)`0QM`H{RcCiFoJ(i z> ze*omDU)bh3o6gx09?AUxVvQH>J4=6m5A?>&sQA^e^xu6xhL(fMprCc=7xWH2+UB7P z7m{Hi&X1sH(7Ut-9faSYi*yb;`7(UmeN-T!_FPWc)ERpkSEq+rMyRm-8-LQbA~DOn zU94$D{>4sYb`Qs1jIS;B*fDB&qJ;Rv&WJEwgQvu!687_#Tvoer8H3Nv;9rI*X5yDG`Gu}V|)p247E~6jgOQ3~+tHluW*O5qU@{1WsO4bb92OZQQQN z6z{r9b4I=~@U*K&zM#FMvq^5zRCEY`s$U_6YFCK5c2(KWgGe?Y(uXzC2!dUXs)8tg z9EEg7TiqLK^=t)FLjSt4l_H}DztYK<=Ht0(o>U*RRTW54^KWiyK9PT`saUG{W^OeP zrdadZsx?n&m-_F|C-l2Oy>V9YIy_a9>~fDZi<@>`<-_6dCy6EL2Zu{|5y1rUxsApd zc~6vTn!`Uy0Y)%+jdaZ(Mo&7Ae*KqnR#y}$>+3dof?T4v5<-n!1BsM$UOiG4)-bH@ z>CIa{{YzXG)tB7mI1+!0@qLKth`1whU~AZhzJP;e=B|MW`X9v;C?A#(7Trx7JSl(l zw|9ode0}f#2}AI3?%(tFFHo1C{P6GeMU(RcQ*3473|VTl{Z(+`LQ)Z73H!rgGk$#k zl1;zn9SsOM6SigBi+KAx|3s8pfWe1Tex6BiPMZZxc?O%R*fM{Rx!?Y+T59@t*I42y z7$Q#v4OR|}Cx`oR$EFmGE-~c&J;J;nVCek{Zj@fxVAk7$y)6wX1CY`(09TJ0pq4}G ztiu?@Cxe6mL?)S%8Sw}mD+L6F0DuD;1G`D1xX1&*0TIe304-y9#GV1bN?ibofm5;V z&-SS3s6f5agMWW+u?bgz+Gl*AP%U5ZMH+ybN9?tsug9^hjR~QLV$uK#TpB=%O#^EE z=%>jAC|&zf2)9eC-pxrLfrb1L;30(HL_nm1C}7QtGBT+eF!3q~ON2xVEJT3dGiLwQ z5frfnz=KKAbMngL%Q z9@t@yzF}Bhf?mqM@>6~rds}sZi$h@GOm8nVxs7l^>z2WHsP&KHrmg}+p`5v!rUC!? z|5iI-B~5?x{4ENMId(PYB=GrnbM~AIh4a4*I{)Sc&;PGRl^J(_&~ndwx-0Y8dH;b< z>j^Z?%Tmuyf~TYI%5t>~G>wIQ)mLFeWJv;+0(uj6r(OcY!N~gGzxo^WnYW>q4jJz6 z428`pEM|pd=dFb>B(W?^rkPU}q^jWnI¬VjO?rrz_Ux@P_k=0EOn@_b%7ElHd+# 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 diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts index 1fcf444131c1..005f62ee6bd0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadUsageBreakdownGroup } from "./ThreadUsageBreakdownGroup"; -export type ThreadUsage = { threadId: string, estimatedUsageCreditsMicros: bigint, estimatedUsageUsdMicros: bigint | null, groups: Array, }; +export type ThreadUsage = { threadId: string, estimatedUsageCreditsMicros: bigint, estimatedUsageUsdMicros: bigint | null, +/** + * Native dollar usage, independent of credit estimates. None means unknown, not zero. + */ +nativeUsageUsdMicros?: bigint | null, groups: Array, }; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index ebd08d62a155..6e1e5f48be9f 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2409,6 +2409,8 @@ mod tests { let legacy_optional_nullable_field = (path == Path::new("v2/GetAccountTokenUsageResponse.ts") && field_prefix.trim() == "threadUsage?") + || (path == Path::new("v2/ThreadUsage.ts") + && field_prefix.trim() == "nativeUsageUsdMicros?") || (path == Path::new("v2/McpServerOauthLoginCompletedNotification.ts") && field_prefix.trim() == "loginId?") || (path == Path::new("v2/MisalignmentErrorDetails.ts") diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs index 249cfa969b2d..b48e35ecddcf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs @@ -1,3 +1,5 @@ +//! Thread billing amounts, keeping native dollars separate from credit estimates. + use crate::JsonSchema; use crate::TS; use serde::Deserialize; @@ -10,6 +12,9 @@ pub struct ThreadUsage { pub thread_id: String, pub estimated_usage_credits_micros: i64, pub estimated_usage_usd_micros: Option, + /// Native dollar usage, independent of credit estimates. None means unknown, not zero. + #[ts(optional, as = "Option>")] + pub native_usage_usd_micros: Option, pub groups: Vec, } diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index cf4c6406bcb6..43946dc1ed7b 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -1295,6 +1295,7 @@ impl AccountRequestProcessor { thread_id: usage.thread_id, estimated_usage_credits_micros: usage.estimated_usage_credits_micros, estimated_usage_usd_micros: usage.estimated_usage_usd_micros, + native_usage_usd_micros: usage.native_usage_usd_micros, groups: usage .groups .into_iter() diff --git a/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs b/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs index 17e507ccf786..902dd6a55336 100644 --- a/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs +++ b/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs @@ -103,6 +103,7 @@ async fn account_thread_usage_uses_active_workspace_and_canonical_thread_ids() - thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: None, + native_usage_usd_micros: None, groups: vec![ThreadUsageBreakdownGroup { model: Some("gpt-5.4".to_string()), reasoning_effort: Some("high".to_string()), @@ -132,7 +133,7 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re let access_token = encode_id_token( &ChatGptIdTokenClaims::new() .email("external@example.com") - .plan_type("business") + .plan_type("ent26") .chatgpt_account_id("external-workspace"), )?; Mock::given(method("GET")) @@ -149,7 +150,7 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re .send_chatgpt_auth_tokens_login_request( access_token.clone(), "external-workspace".to_string(), - Some("business".to_string()), + Some("ent26".to_string()), ) .await?; let login: LoginAccountResponse = @@ -164,8 +165,9 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ "threads": [{ "thread_id": thread_id, - "estimated_usage_credits_micros": 21_000_000, - "estimated_usage_usd_micros": 840_000 + "estimated_usage_credits_micros": 0, + "estimated_usage_usd_micros": 0, + "native_usage_usd_micros": 750_000 }] }))) .expect(/*r*/ 1) @@ -181,8 +183,9 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re response.thread_usage, Some(ThreadUsage { thread_id: thread_id.to_string(), - estimated_usage_credits_micros: 21_000_000, - estimated_usage_usd_micros: Some(840_000), + estimated_usage_credits_micros: 0, + estimated_usage_usd_micros: Some(0), + native_usage_usd_micros: Some(750_000), groups: Vec::new(), }) ); diff --git a/codex-rs/tui/src/app/agents_overview_details.rs b/codex-rs/tui/src/app/agents_overview_details.rs index 02146046433c..5a8fa768c355 100644 --- a/codex-rs/tui/src/app/agents_overview_details.rs +++ b/codex-rs/tui/src/app/agents_overview_details.rs @@ -224,7 +224,12 @@ impl App { usage_lines: ThreadId::from_string(&root.id) .ok() .and_then(|id| self.agents_overview.usage.get(&id)) - .map(super::agents_overview_usage::usage_lines) + .map(|usage| { + super::agents_overview_usage::usage_lines( + usage, + self.chat_widget.current_plan_type(), + ) + }) .unwrap_or_default(), last_message, } diff --git a/codex-rs/tui/src/app/agents_overview_usage.rs b/codex-rs/tui/src/app/agents_overview_usage.rs index 1c0f41bca904..fe7f7f2313c5 100644 --- a/codex-rs/tui/src/app/agents_overview_usage.rs +++ b/codex-rs/tui/src/app/agents_overview_usage.rs @@ -12,6 +12,7 @@ use crate::chatwidget::ThreadUsageOutcome; use crate::status::format_credit_micros; use crate::status::format_estimated_usd_micros; use crate::status::format_tokens_compact; +use crate::status::thread_cost_micros; use crate::tui::FrameRequester; use codex_app_server_protocol::ThreadUsage; use codex_app_server_protocol::ThreadUsageBreakdownGroup; @@ -46,7 +47,8 @@ impl App { || !matches!( self.chat_widget.current_plan_type(), Some( - PlanType::Business + PlanType::Ent26 + | PlanType::Business | PlanType::EnterpriseCbpUsageBased | PlanType::EnterpriseCbpAutomation ) @@ -144,7 +146,10 @@ impl App { } } -pub(super) fn usage_lines(usage: &AgentsOverviewUsage) -> Vec> { +pub(super) fn usage_lines( + usage: &AgentsOverviewUsage, + plan_type: Option, +) -> Vec> { let sum = |count: fn(&ThreadUsageBreakdownGroup) -> Option| { let groups = &usage.estimate.as_ref()?.groups; if groups.is_empty() { @@ -179,15 +184,14 @@ pub(super) fn usage_lines(usage: &AgentsOverviewUsage) -> Vec> { } if let Some(estimate) = &usage.estimate { let mut values = Vec::new(); - if estimate.estimated_usage_credits_micros >= 0 { + if plan_type != Some(PlanType::Ent26) && estimate.estimated_usage_credits_micros >= 0 { values.push(format!( "{} credits", format_credit_micros(estimate.estimated_usage_credits_micros) )); } - if let Some(cost) = estimate - .estimated_usage_usd_micros - .and_then(format_estimated_usd_micros) + if let Some(cost) = + thread_cost_micros(estimate, plan_type).and_then(format_estimated_usd_micros) { values.push(cost); } diff --git a/codex-rs/tui/src/app/agents_overview_usage_tests.rs b/codex-rs/tui/src/app/agents_overview_usage_tests.rs index b8c321a30441..af459f856dd3 100644 --- a/codex-rs/tui/src/app/agents_overview_usage_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_usage_tests.rs @@ -199,6 +199,47 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R assert_eq!(app.agents_overview.pending_usage, None); assert!(!app.agents_overview.usage_disabled); } + app.chat_widget.update_account_state( + /*status_account_display*/ None, + Some(PlanType::Ent26), + /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ true, + ); + let view = app.agents_overview_view(threads.to_vec(), Some(selected)); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.refresh_agents_overview_usage(&app_server, tui.frame_requester()); + let (thread_id, request_id) = app + .agents_overview + .pending_usage + .expect("request ENT26 usage"); + assert_eq!(thread_id, selected); + let mut native_usage = expected.clone(); + native_usage.estimated_usage_credits_micros = 0; + native_usage.estimated_usage_usd_micros = Some(0); + native_usage.native_usage_usd_micros = Some(750_000); + app.finish_agents_overview_usage( + thread_id, + request_id, + Ok(ThreadUsageOutcome::Available(native_usage)), + ); + let view = app.agents_overview_view(threads.to_vec(), Some(selected)); + let rendered = view + .rows + .iter() + .find(|row| row.thread_id == selected) + .unwrap() + .details + .usage_lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + insta::assert_snapshot!(rendered, @" + Tokens: 12K in · 3K out + Est. usage: ~$0.75 + "); + app.agents_overview.usage_disabled = true; app.agents_overview.pending_usage = Some((thread_id, request_id)); app.app_server_target = AppServerTarget::LocalDaemon { @@ -231,7 +272,7 @@ fn incomplete_billing_groups_do_not_display_partial_token_totals() { .unwrap(); let mut usage = AgentsOverviewUsage::default(); usage.estimate = Some(estimate); - let text = usage_lines(&usage) + let text = usage_lines(&usage, Some(PlanType::Business)) .into_iter() .map(|line| line.to_string()) .collect::>(); diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 5133f7f96e2f..ad5f6e3d7ba4 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -1428,8 +1428,9 @@ mod tests { serde_json::json!({ "threads": [{ "thread_id": thread_id.to_string(), - "estimated_usage_credits_micros": 46_000_000, - "estimated_usage_usd_micros": 1_820_000 + "estimated_usage_credits_micros": 0, + "estimated_usage_usd_micros": 0, + "native_usage_usd_micros": 750_000 }] }), )) @@ -1442,9 +1443,10 @@ mod tests { .await .expect("read thread usage through the authenticated app server"), ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: Some(750_000), thread_id: thread_id.to_string(), - estimated_usage_credits_micros: 46_000_000, - estimated_usage_usd_micros: Some(1_820_000), + estimated_usage_credits_micros: 0, + estimated_usage_usd_micros: Some(0), groups: Vec::new(), }) ); diff --git a/codex-rs/tui/src/app/tests/thread_usage.rs b/codex-rs/tui/src/app/tests/thread_usage.rs index 8d6d408de69a..6a5ad7a79e10 100644 --- a/codex-rs/tui/src/app/tests/thread_usage.rs +++ b/codex-rs/tui/src/app/tests/thread_usage.rs @@ -56,6 +56,7 @@ fn successful_thread_usage(thread_id: ThreadId, request_id: u64) -> AppEvent { thread_id, request_id, result: Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(1_820_000), diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index a5d020c7494b..046c55bc19b6 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -5,6 +5,7 @@ //! history-facing `/status` surface. use super::*; +use crate::status::thread_cost_micros; impl ChatWidget { /// Update the status indicator header and details. @@ -385,7 +386,7 @@ impl ChatWidget { if self .estimated_thread_usage() - .is_some_and(|usage| usage.estimated_usage_usd_micros.is_none()) + .is_some_and(|usage| thread_cost_micros(usage, self.plan_type).is_none()) { preview_data.suppress_placeholder(StatusSurfacePreviewItem::EstimatedThreadCost); } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index d7340e54379c..f455721809df 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -13,6 +13,7 @@ use crate::model_catalog::LUNA_RESERVE_MODEL; use crate::status::format_credit_micros; use crate::status::format_estimated_usd_micros; use crate::status::format_tokens_compact; +use crate::status::thread_cost_micros; use crate::terminal_program_status::ProgramStatus; use codex_app_server_protocol::AskForApproval; use codex_config::ConfigLayerSource; @@ -807,11 +808,12 @@ impl ChatWidget { }), StatusLineItem::ThreadCredits => self .estimated_thread_usage() + .filter(|_| self.plan_type != Some(PlanType::Ent26)) .map(|usage| usage.estimated_usage_credits_micros) .map(|credits| format!("{} credits", format_credit_micros(credits))), StatusLineItem::EstimatedThreadCost => self .estimated_thread_usage() - .and_then(|usage| usage.estimated_usage_usd_micros) + .and_then(|usage| thread_cost_micros(usage, self.plan_type)) .and_then(format_estimated_usd_micros), StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), StatusLineItem::FastMode => self diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 00dab60b7dfe..fcb59879f392 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3117,6 +3117,7 @@ async fn status_line_estimated_thread_cost_fetches_and_renders_backend_estimate( thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -3153,6 +3154,7 @@ async fn status_line_thread_credits_fetches_and_renders_fractional_credits() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_240_000, estimated_usage_usd_micros: Some(210_000), @@ -3186,6 +3188,7 @@ async fn status_line_thread_credits_and_cost_share_one_backend_request() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: Some(210_000), @@ -3220,6 +3223,7 @@ async fn status_line_thread_credits_remain_visible_when_usd_is_unavailable() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: None, @@ -3262,6 +3266,7 @@ async fn terminal_title_thread_usage_fetches_and_renders_without_status_line() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: Some(210_000), @@ -3299,6 +3304,7 @@ async fn terminal_title_thread_credits_remain_visible_when_usd_is_unavailable() thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: None, @@ -3336,6 +3342,7 @@ async fn terminal_title_and_status_line_share_one_thread_usage_request() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: Some(210_000), @@ -3409,6 +3416,7 @@ async fn status_line_estimated_thread_cost_preserves_cached_amount_during_settle thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -3425,6 +3433,7 @@ async fn status_line_estimated_thread_cost_preserves_cached_amount_during_settle thread_id, settlement_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 0, estimated_usage_usd_micros: Some(0), @@ -3457,6 +3466,7 @@ async fn completed_turn_refreshes_estimated_thread_cost() { thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -3481,6 +3491,7 @@ async fn completed_turn_refreshes_estimated_thread_cost() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(2_100_000), @@ -3509,6 +3520,7 @@ async fn completed_turn_refreshes_credits_only_terminal_title() { thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: None, @@ -3533,6 +3545,7 @@ async fn completed_turn_refreshes_credits_only_terminal_title() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 6_200_000, estimated_usage_usd_micros: None, @@ -3560,6 +3573,7 @@ async fn interrupted_turn_refreshes_estimated_thread_usage() { thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -3601,6 +3615,7 @@ async fn failed_turn_refreshes_estimated_thread_usage() { thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -3652,6 +3667,7 @@ async fn status_line_estimated_thread_cost_rejects_stale_thread_completions() { previous_thread_id, previous_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: previous_thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -3664,6 +3680,7 @@ async fn status_line_estimated_thread_cost_rejects_stale_thread_completions() { active_thread_id, active_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: active_thread_id.to_string(), estimated_usage_credits_micros: 1_000_000, estimated_usage_usd_micros: Some(400), @@ -3698,6 +3715,7 @@ async fn status_line_estimated_thread_cost_footer_snapshot() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: Some(210_000), diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index fb7a21c25c25..b6daf7a33531 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -243,6 +243,7 @@ async fn status_command_renders_immediately_and_updates_first_card_without_statu thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -302,10 +303,9 @@ async fn status_command_renders_immediately_and_updates_first_card_without_statu } #[tokio::test] -async fn status_command_omits_thread_usage_for_non_cbp_enterprise_plans() { +async fn status_command_omits_thread_usage_for_unsupported_plans() { for plan_type in [ Some(PlanType::Enterprise), - Some(PlanType::Ent26), Some(PlanType::Pro), Some(PlanType::Edu), Some(PlanType::SelfServeBusinessUsageBased), @@ -373,6 +373,7 @@ async fn status_command_stays_visible_and_updates_after_thread_usage_retry() { thread_id, retry_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -410,6 +411,7 @@ async fn status_command_renders_credits_and_breakdowns_without_usd_estimate() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: None, @@ -475,6 +477,7 @@ async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 40_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -500,6 +503,7 @@ async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: None, @@ -585,6 +589,7 @@ async fn status_command_remains_visible_when_account_changes_during_usage_refres thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -617,6 +622,7 @@ async fn status_command_remains_visible_when_thread_changes_during_usage_refresh thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(1_820_000), diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8b8416595c7d..a05f180af256 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -304,6 +304,7 @@ async fn status_surface_preview_lines_thread_usage_snapshot() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: Some(210_000), @@ -351,6 +352,7 @@ async fn status_surface_thread_usage_previews_omit_unavailable_usd_estimates() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 5_200_000, estimated_usage_usd_micros: None, diff --git a/codex-rs/tui/src/chatwidget/thread_usage.rs b/codex-rs/tui/src/chatwidget/thread_usage.rs index 9907aa1c694e..19ba15a93c00 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage.rs @@ -9,6 +9,7 @@ use super::StatusLineItem; use super::TerminalTitleItem; use super::ThreadId; use crate::status::StatusHistoryHandle; +use crate::status::thread_cost_micros; use codex_app_server_protocol::ThreadUsage; #[derive(Clone, Debug, PartialEq, Eq)] @@ -98,7 +99,7 @@ impl ChatWidget { .thread_usage .estimate .as_ref() - .and_then(|estimate| estimate.estimated_usage_usd_micros); + .and_then(|estimate| thread_cost_micros(estimate, self.plan_type)); self.schedule_next_thread_usage_settlement(); self.request_thread_usage(); } @@ -252,7 +253,7 @@ impl ChatWidget { usage.groups.clone_from(&previous_usage.groups); } let estimated_credits_micros = usage.estimated_usage_credits_micros; - let estimated_usd_micros = usage.estimated_usage_usd_micros; + let estimated_usd_micros = thread_cost_micros(&usage, self.plan_type); self.thread_usage.estimate = Some(usage); if let Some(baseline) = self.thread_usage.settlement_baseline_credits_micros { if !post_turn_request { @@ -268,10 +269,10 @@ impl ChatWidget { } else { let mut selected = self.selected_thread_usage(); if !self.thread_usage.status_history_handles.is_empty() { - selected.credits = true; - selected.estimated_cost |= - self.thread_usage.settlement_baseline_usd_micros.is_some() - || estimated_usd_micros.is_some(); + selected.credits = self.plan_type != Some(PlanType::Ent26); + selected.estimated_cost |= self.plan_type == Some(PlanType::Ent26) + || self.thread_usage.settlement_baseline_usd_micros.is_some() + || estimated_usd_micros.is_some(); } let credits_settled = !selected.credits || estimated_credits_micros > baseline; @@ -361,7 +362,8 @@ impl ChatWidget { && matches!( self.plan_type, Some( - PlanType::Business + PlanType::Ent26 + | PlanType::Business | PlanType::EnterpriseCbpUsageBased | PlanType::EnterpriseCbpAutomation ) @@ -389,6 +391,7 @@ impl ChatWidget { Ok(_) | Err(_) => {} } } + selected.credits &= self.plan_type != Some(PlanType::Ent26); selected } diff --git a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs index 64aeca360a19..f0b525de102e 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs @@ -35,6 +35,7 @@ fn thread_usage( estimated_usage_usd_micros: Option, ) -> ThreadUsage { ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros, estimated_usage_usd_micros, @@ -472,27 +473,44 @@ async fn transient_zero_credits_preserves_credits_only_estimates() { #[tokio::test] async fn cost_settlement_waits_for_new_or_missing_usd_estimates() { - for (previous_usd_micros, stale_usd_micros) in [ - (Some(1_820_000), Some(1_820_000)), - (None, None), - (None, Some(0)), + for (plan_type, previous_usd_micros, stale_usd_micros) in [ + (PlanType::Business, Some(1_820_000), Some(1_820_000)), + (PlanType::Business, None, None), + (PlanType::Business, None, Some(0)), + (PlanType::Ent26, Some(1_820_000), Some(1_820_000)), + (PlanType::Ent26, None, None), + (PlanType::Ent26, None, Some(0)), + (PlanType::Ent26, Some(1_820_000), Some(0)), ] { let (mut chat, _sender, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; let thread_id = ThreadId::new(); + let [previous_usage, stale_usage, settled_usage] = [ + (40_000_000, previous_usd_micros), + (50_000_000, stale_usd_micros), + (50_000_000, Some(2_100_000)), + ] + .map(|(credits, cost)| { + let mut usage = thread_usage(thread_id, credits, cost); + usage.native_usage_usd_micros = cost; + if plan_type == PlanType::Ent26 { + usage.estimated_usage_credits_micros = 0; + usage.estimated_usage_usd_micros = Some(0); + } + usage + }); + let previous_credits_micros = previous_usage.estimated_usage_credits_micros; chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; - chat.plan_type = Some(PlanType::Business); + chat.plan_type = Some(plan_type); chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.terminal_title = + (plan_type == PlanType::Ent26).then(|| vec!["thread-credits".to_string()]); chat.refresh_status_line(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( thread_id, request_id, - Ok(ThreadUsageOutcome::Available(thread_usage( - thread_id, - /*estimated_usage_credits_micros*/ 40_000_000, - previous_usd_micros, - ))), + Ok(ThreadUsageOutcome::Available(previous_usage)), )); chat.refresh_thread_usage_after_turn(); @@ -500,15 +518,17 @@ async fn cost_settlement_waits_for_new_or_missing_usd_estimates() { assert!(chat.finish_thread_usage_refresh( thread_id, request_id, - Ok(ThreadUsageOutcome::Available(thread_usage( - thread_id, - /*estimated_usage_credits_micros*/ 50_000_000, - stale_usd_micros, - ))), + Ok(ThreadUsageOutcome::Available(stale_usage)), )); + if plan_type == PlanType::Ent26 { + assert_eq!( + chat.status_line_text(), + stale_usd_micros.and_then(format_estimated_usd_micros) + ); + } assert_eq!( chat.thread_usage.settlement_baseline_credits_micros, - Some(40_000_000) + Some(previous_credits_micros) ); assert_eq!( chat.thread_usage.settlement_baseline_usd_micros, @@ -522,11 +542,7 @@ async fn cost_settlement_waits_for_new_or_missing_usd_estimates() { assert!(chat.finish_thread_usage_refresh( thread_id, request_id, - Ok(ThreadUsageOutcome::Available(thread_usage( - thread_id, - /*estimated_usage_credits_micros*/ 50_000_000, - Some(2_100_000), - ))), + Ok(ThreadUsageOutcome::Available(settled_usage)), )); assert_eq!(chat.status_line_text(), Some("~$2.10".to_string())); @@ -635,6 +651,7 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() { PlanType::Business, PlanType::EnterpriseCbpUsageBased, PlanType::EnterpriseCbpAutomation, + PlanType::Ent26, ] { let (mut chat, _sender, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; let thread_id = ThreadId::new(); @@ -651,13 +668,45 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() { assert!(chat.finish_thread_usage_refresh( thread_id, request_id, - Ok(ThreadUsageOutcome::Available(thread_usage( - thread_id, - /*estimated_usage_credits_micros*/ 5_200_000, - Some(210_000), - ))), + Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: Some(750_000), + ..thread_usage( + thread_id, + if plan_type == PlanType::Ent26 { + 0 + } else { + 5_200_000 + }, + Some(if plan_type == PlanType::Ent26 { + 0 + } else { + 210_000 + }), + ) + })), )); + assert!(rx.try_recv().is_err(), "billing surfaces share a request"); + if plan_type == PlanType::Ent26 { + insta::assert_snapshot!(chat.status_line_text().unwrap(), @"~$0.75"); + insta::assert_snapshot!(chat.last_terminal_title.as_ref().unwrap(), @"~$0.75"); + chat.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + let status_cell = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + event => panic!("expected /status card, got {event:?}"), + }; + let usage_row = status_cell + .display_lines(/*width*/ 90) + .into_iter() + .map(|line| line.to_string()) + .find(|line| line.contains("Thread usage:")) + .unwrap(); + insta::assert_snapshot!(usage_row, @" Thread usage: ~$0.75"); + continue; + } + assert_eq!( chat.status_line_text(), Some("5.2 credits · ~$0.21".to_string()) @@ -666,7 +715,6 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() { chat.last_terminal_title, Some("5.2 credits | ~$0.21".to_string()) ); - assert!(rx.try_recv().is_err(), "billing surfaces share a request"); } } @@ -726,6 +774,7 @@ async fn unchanged_thread_usage_has_bounded_settlement_refreshes() { thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -745,6 +794,7 @@ async fn unchanged_thread_usage_has_bounded_settlement_refreshes() { thread_id, request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -783,6 +833,7 @@ async fn early_draw_rearms_thread_usage_settlement_after_immediate_redraw() { event => panic!("expected initial thread usage request, got {event:?}"), }; let usage = ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -848,6 +899,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion( thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 40_000_000, estimated_usage_usd_micros: Some(1_600_000), @@ -872,6 +924,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion( thread_id, pre_turn_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -892,6 +945,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion( thread_id, post_turn_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -910,6 +964,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion( thread_id, settlement_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(2_100_000), @@ -939,6 +994,7 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache thread_id, initial_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -981,6 +1037,7 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache assert_eq!( chat.estimated_thread_usage(), Some(&ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), @@ -991,6 +1048,7 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache thread_id, settlement_request_id, Ok(ThreadUsageOutcome::Available(ThreadUsage { + native_usage_usd_micros: None, thread_id: thread_id.to_string(), estimated_usage_credits_micros: 50_000_000, estimated_usage_usd_micros: Some(2_100_000), diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 65ef9f651d7e..44d45e4976a6 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -140,6 +140,7 @@ struct StatusHistoryCell { token_usage: StatusTokenUsageData, rate_limit_state: Arc>, thread_usage: StatusThreadUsage, + plan_type: Option, } #[cfg(test)] @@ -399,6 +400,7 @@ impl StatusHistoryCell { agents_summary, rate_limit_state, thread_usage, + plan_type: _plan_type, } } @@ -862,7 +864,9 @@ impl StatusHistoryCell { } lines.extend(self.rate_limit_lines(&rate_limit_state, available_width, &formatter)); - let thread_usage_lines = self.thread_usage.lines(&formatter, value_width); + let thread_usage_lines = self + .thread_usage + .lines(&formatter, value_width, self.plan_type); if !thread_usage_lines.is_empty() { lines.push(Line::from(Vec::>::new())); lines.extend(thread_usage_lines); diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index 8f46cfc0dd1f..a79c95da7ffa 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -33,6 +33,7 @@ pub(crate) use rate_limits::rate_limit_snapshot_display; pub(crate) use rate_limits::rate_limit_snapshot_display_for_limit; pub(crate) use thread_usage::format_credit_micros; pub(crate) use thread_usage::format_estimated_usd_micros; +pub(crate) use thread_usage::thread_cost_micros; #[cfg(test)] mod tests; diff --git a/codex-rs/tui/src/status/thread_usage.rs b/codex-rs/tui/src/status/thread_usage.rs index 60948689fd46..ef419ad25228 100644 --- a/codex-rs/tui/src/status/thread_usage.rs +++ b/codex-rs/tui/src/status/thread_usage.rs @@ -7,6 +7,7 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; use codex_app_server_protocol::ThreadUsage; use codex_app_server_protocol::ThreadUsageBreakdownGroup; +use codex_protocol::account::PlanType; use ratatui::prelude::Line; use ratatui::style::Stylize; use std::collections::BTreeMap; @@ -32,6 +33,15 @@ const REASONING_LABEL: &str = " Reasoning"; const SPEED_LABEL: &str = " Speed"; const BILLED_TOKENS_LABEL: &str = " Billed tokens"; +/// ENT26 is billed directly in dollars; missing native usage is not a zero estimate. +pub(crate) fn thread_cost_micros(usage: &ThreadUsage, plan_type: Option) -> Option { + if plan_type == Some(PlanType::Ent26) { + usage.native_usage_usd_micros + } else { + usage.estimated_usage_usd_micros + } +} + /// Shared state updates a committed `/status` card when its asynchronous estimate arrives. #[derive(Clone, Debug, Default)] pub(crate) struct StatusThreadUsage { @@ -87,6 +97,7 @@ impl StatusThreadUsage { &self, formatter: &FieldFormatter, value_width: usize, + plan_type: Option, ) -> Vec> { #[expect(clippy::expect_used)] let stored_estimate = self @@ -97,6 +108,14 @@ impl StatusThreadUsage { return Vec::new(); }; + if plan_type == Some(PlanType::Ent26) { + // Credit-weighted breakdowns do not describe native dollar usage. + return thread_cost_micros(estimate, plan_type) + .and_then(format_estimated_usd_micros) + .map(|cost| vec![formatter.line("Thread usage", vec![cost.into()])]) + .unwrap_or_default(); + } + let credits_micros = estimate.estimated_usage_credits_micros; let mut usage = vec![format!("{} credits", format_credit_micros(credits_micros)).into()]; if let Some(cost) = estimate diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index fcd8484fedad..0551659b2900 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -10784,6 +10784,13 @@ class ThreadUsage(BaseModel): estimated_usage_credits_micros: Annotated[int, Field(alias="estimatedUsageCreditsMicros")] estimated_usage_usd_micros: Annotated[int | None, Field(alias="estimatedUsageUsdMicros")] = None groups: list[ThreadUsageBreakdownGroup] + native_usage_usd_micros: Annotated[ + int | None, + Field( + alias="nativeUsageUsdMicros", + description="Native dollar usage, independent of credit estimates. None means unknown, not zero.", + ), + ] = None thread_id: Annotated[str, Field(alias="threadId")] From 987b8dfc0fa33acd45a67944a20fdea361d52eb0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 26 Oct 2026 15:19:53 +0000 Subject: [PATCH 068/287] Allow slow daemon startup while detecting early process exits (#57253) ## Why On Linux, a cold start from a rotational disk can exceed the ten-second readiness timeout. Extending that timeout should still let startup failures surface promptly when the daemon exits. ## What changed - Increase the Linux readiness timeout to 30 seconds, matching macOS. - Stop waiting for readiness when the published daemon process exits, preserving the PID reservation and reporting startup diagnostics, including stderr. - Kill pending `ps` subprocesses when their process checks are dropped. ## Testing Add coverage for a managed daemon becoming ready after 15 seconds and for reporting an exited daemon's stderr within five seconds. GitOrigin-RevId: c663e52bea9039807f14ef8fc4b44aca9df3df21 --- codex-rs/app-server-daemon/src/backend/mod.rs | 1 + codex-rs/app-server-daemon/src/backend/pid.rs | 15 ++++++ codex-rs/app-server-daemon/src/lib.rs | 46 +++++++++++++------ .../src/update_loop_tests.rs | 23 ++++++++++ 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server-daemon/src/backend/mod.rs b/codex-rs/app-server-daemon/src/backend/mod.rs index 1d2f4ecfacef..1c2ea755e939 100644 --- a/codex-rs/app-server-daemon/src/backend/mod.rs +++ b/codex-rs/app-server-daemon/src/backend/mod.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; use serde::Serialize; pub(crate) use pid::PidBackend; +pub(crate) use pid::wait_for_process_exit; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index 60e793e18d76..b80743523c6d 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -460,6 +460,20 @@ impl PidBackend { } } +/// Wait for a published process to exit without modifying its PID reservation. +pub(crate) async fn wait_for_process_exit(pid_file: &Path) { + loop { + // A missing or empty record can belong to a launch still publishing its PID. + if let Ok(contents) = fs::read(pid_file).await + && let Ok(record) = serde_json::from_slice::(&contents) + && matches!(process_matches_record(&record).await, Ok(false)) + { + return; + } + sleep(crate::START_POLL_INTERVAL).await; + } +} + pub(crate) async fn read_stderr_log_tail(pid_file: &Path) -> Result> { let path = stderr_log_file_for_pid_file(pid_file); let Some(contents) = read_log_tail(&path, STDERR_LOG_TAIL_BYTES).await? else { @@ -757,6 +771,7 @@ async fn read_process_start_time(pid: u32) -> Result { async fn read_process_details(pid: u32) -> Result<(String, String)> { let output = Command::new("ps") .args(["-p", &pid.to_string(), "-o", "stat=", "-o", "lstart="]) + .kill_on_drop(true) .output() .await .context("failed to invoke ps for pid-managed app server")?; diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 0c4f95ff2fce..51b9c72e72f8 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -50,10 +50,11 @@ use tokio::time::sleep; const START_POLL_INTERVAL: Duration = Duration::from_millis(50); // An older updater can pass background policy to a replacement daemon, so retain -// the longer readiness allowance during upgrades on macOS. -#[cfg(target_os = "macos")] +// the longer readiness allowance during upgrades on macOS. On Linux, a cold +// start from a rotational disk can take more than ten seconds. +#[cfg(any(target_os = "macos", target_os = "linux"))] const START_TIMEOUT: Duration = Duration::from_secs(30); -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "linux")))] const START_TIMEOUT: Duration = Duration::from_secs(10); // Leave room for the longest graceful stop, forced-exit check, and restart. const OPERATION_LOCK_TIMEOUT: Duration = @@ -701,20 +702,22 @@ impl Daemon { } async fn wait_until_ready(&self) -> Result { + let exited = backend::wait_for_process_exit(&self.pid_file); + tokio::pin!(exited); let deadline = tokio::time::Instant::now() + START_TIMEOUT; - loop { + let err = loop { match client::probe(&self.socket_path).await { Ok(info) => return Ok(info), Err(err) if tokio::time::Instant::now() < deadline => { - let _ = err; - sleep(START_POLL_INTERVAL).await; - } - Err(err) => { - let context = self.app_server_not_ready_context().await; - return Err(err).context(context); + tokio::select! { + () = &mut exited => break err, + () = sleep(START_POLL_INTERVAL) => {} + } } + Err(err) => break err, } - } + }; + Err(err).context(self.app_server_not_ready_context().await) } async fn app_server_not_ready_context(&self) -> String { @@ -1523,7 +1526,7 @@ mod tests { } #[tokio::test] - async fn not_ready_context_reports_daemon_app_server_before_stderr() { + async fn readiness_reports_stderr_promptly_when_daemon_is_gone() { let temp_dir = TempDir::new().expect("temp dir"); let daemon = Daemon { log_diagnostics: false, @@ -1539,8 +1542,25 @@ mod tests { .await .expect("write stderr log"); + // A daemon that exits during startup leaves its published PID record behind. + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--help") + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + child.wait().unwrap(); + std::fs::write( + &daemon.pid_file, + serde_json::json!({ "pid": child.id(), "processStartTime": "exited" }).to_string(), + ) + .unwrap(); + let error = + tokio::time::timeout(std::time::Duration::from_secs(5), daemon.wait_until_ready()) + .await + .expect("an exited daemon should not exhaust the readiness timeout") + .expect_err("daemon is gone"); assert_eq!( - daemon.app_server_not_ready_context().await, + error.to_string(), format!( "app server did not become ready on {}\n\n\ Daemon used app-server:\n path: {}\n version: unknown\n\n\ diff --git a/codex-rs/app-server-daemon/src/update_loop_tests.rs b/codex-rs/app-server-daemon/src/update_loop_tests.rs index 02aca068a152..3eaf610e3d9e 100644 --- a/codex-rs/app-server-daemon/src/update_loop_tests.rs +++ b/codex-rs/app-server-daemon/src/update_loop_tests.rs @@ -829,6 +829,29 @@ async fn manual_update_restarts_managed_daemon_with_automatic_updates_disabled() check_manual_update_restart("standalone").await; } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[tokio::test] +async fn readiness_waits_for_a_slow_managed_daemon() { + let home = TempDir::new().unwrap(); + let (daemon, _) = manual_update_daemon(&home); + let settings = crate::settings::DaemonSettings::default(); + let backend = crate::backend::pid_backend(daemon.backend_paths(&settings)); + backend.start().await.unwrap(); + + tokio::time::pause(); + let (ready, listener) = tokio::join!(daemon.wait_until_ready(), async { + // Model a cold start that takes longer than the old Linux readiness deadline. + tokio::time::sleep(Duration::from_secs(15)).await; + tokio::time::resume(); + test_control_server(&daemon, home.path()) + .await + .abort_handle() + }); + backend.stop().await.unwrap(); + listener.abort(); + assert_eq!(ready.unwrap().app_server_version, "1.0.0"); +} + #[cfg(unix)] #[tokio::test] async fn daemon_start_and_restart_preserve_launch_features() { From 438cfc55c8411e682db77444af509833c2045eb5 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 26 Oct 2026 15:49:05 +0000 Subject: [PATCH 069/287] Record reviewed exceptions for zstd security advisories (#57255) ## What changed Add four advisory exceptions to `codex-rs/.cargo/audit.toml` and `codex-rs/deny.toml`, documenting the usage constraints and removal criteria: - `RUSTSEC-2026-0391`: borrowed contexts are not reused across streams; remove after upgrading to `zstd >=0.14.3` on all paths. - `RUSTSEC-2026-0398`: the affected seekable feature is disabled; remove after upgrading to `zstd-safe >=8.2.0` on all paths. - `RUSTSEC-2026-0403`: callers use read decoders or initialized output buffers and discard output on error; remove after upgrading to `zstd-safe >=8.2.0` on all paths. - `RUSTSEC-2026-0410`: the allocator bug affects `wasm32`, while Codex ships native binaries; remove when the lockfile uses `zstd-sys >=2.1.2`. GitOrigin-RevId: 556e53be961b01191590e2ca4e9f08dd9f017eac --- codex-rs/.cargo/audit.toml | 5 +++++ codex-rs/deny.toml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/codex-rs/.cargo/audit.toml b/codex-rs/.cargo/audit.toml index 743010925f11..987c9d4b3fec 100644 --- a/codex-rs/.cargo/audit.toml +++ b/codex-rs/.cargo/audit.toml @@ -18,4 +18,9 @@ ignore = [ # Reviewed 2026-10-20. "RUSTSEC-2026-0374", # basic-toml 0.1.10 via i18n-config/i18n-embed-fl under age 0.12.1 in codex-secrets; no patched release; remove when age's localization dependencies migrate off basic-toml "RUSTSEC-2026-0387", # crypto-bigint 0.5.5 via elliptic-curve 0.13.8 is used by age, AWS login, user verification, and WebRTC; timing leakage can affect private-key or session-secret operations; remove when these dependency paths use crypto-bigint >=0.7.6 + # Reviewed 2026-10-26. + "RUSTSEC-2026-0391", # Codex, tonic and zip use zstd 0.13.3 without reusing borrowed contexts across streams. Remove when all paths use zstd >=0.14.3. + "RUSTSEC-2026-0398", # zstd 0.13.3 pulls in zstd-safe 7.2.4 with the affected seekable feature disabled. Remove when all paths use zstd-safe >=8.2.0. + "RUSTSEC-2026-0403", # zstd 0.13.3 pulls in zstd-safe 7.2.4. Codex, tonic and zip use read decoders or initialized output buffers and discard output on error. Remove when all paths use zstd-safe >=8.2.0. + "RUSTSEC-2026-0410", # zstd-safe 7.2.4 pulls in zstd-sys 2.0.16. Its allocator bug only affects wasm32, and Codex ships native binaries. Remove when the lockfile uses zstd-sys >=2.1.2. ] diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 57c33b36661e..cef2cb965282 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -90,6 +90,11 @@ ignore = [ # Reviewed 2026-10-20. { id = "RUSTSEC-2026-0374", reason = "basic-toml 0.1.10 is unmaintained; pulled in via i18n-config/i18n-embed-fl under age 0.12.1 used by codex-secrets; no patched release; remove when age's localization dependencies migrate off basic-toml" }, { id = "RUSTSEC-2026-0387", reason = "crypto-bigint 0.5.5 via elliptic-curve 0.13.8 is used by age, AWS login, user verification, and WebRTC; timing leakage can affect private-key or session-secret operations; remove when these dependency paths use crypto-bigint >=0.7.6" }, + # Reviewed 2026-10-26. + { id = "RUSTSEC-2026-0391", reason = "Codex, tonic and zip use zstd 0.13.3 without reusing borrowed contexts across streams. Remove when all paths use zstd >=0.14.3." }, + { id = "RUSTSEC-2026-0398", reason = "zstd 0.13.3 pulls in zstd-safe 7.2.4 with the affected seekable feature disabled. Remove when all paths use zstd-safe >=8.2.0." }, + { id = "RUSTSEC-2026-0403", reason = "zstd 0.13.3 pulls in zstd-safe 7.2.4. Codex, tonic and zip use read decoders or initialized output buffers and discard output on error. Remove when all paths use zstd-safe >=8.2.0." }, + { id = "RUSTSEC-2026-0410", reason = "zstd-safe 7.2.4 pulls in zstd-sys 2.0.16. Its allocator bug only affects wasm32, and Codex ships native binaries. Remove when the lockfile uses zstd-sys >=2.1.2." }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. From 766f5473f2e37d6043c46c93040276c5edf31554 Mon Sep 17 00:00:00 2001 From: Brent Traut Date: Mon, 26 Oct 2026 16:16:38 +0000 Subject: [PATCH 070/287] Keep parent history out of attached thread views (#57256) ## Why Attached threads need inherited context for model requests while exposing only their own turns to clients. Parent turns and token usage should not appear as activity in an empty attached thread. ## What changed - Stop attached-thread history traversal at the parent boundary, including after reverts, and hide inherited interruption markers. - Apply visible-history boundaries to search, realtime timelines, and turn-specific fork and revert requests. - Suppress inherited token-usage replay on paginated fork resumes, require fork replay to reference a returned turn, and skip empty turn IDs. - Require a paginated source for `thread/fork` with `attachToSource` and update the protocol documentation. ## Testing Extend regression coverage for attached-thread restart and database recovery, retained parent context in model requests, child-only history, mid-turn fork boundaries, legacy-source rejection, and suppression of parent token-usage notifications. GitOrigin-RevId: 67858caf3bb74b7e79d4dd59bed9c61055fe0ff1 --- .../app-server-exports-experimental.json.zst | Bin 168649 -> 168659 bytes .../src/protocol/v2/thread.rs | 2 +- .../request_processors/thread_processor.rs | 13 ++++- .../request_processors/token_usage_replay.rs | 3 ++ .../tests/suite/v2/attached_thread_tests.rs | 45 +++++++++++------- .../app-server/tests/suite/v2/thread_fork.rs | 35 ++++++++++---- .../tests/suite/v2/thread_resume.rs | 32 +++++++++++++ .../thread-store/src/local/paginated_fork.rs | 10 +++- .../thread-store/src/local/rollout_lineage.rs | 43 ++++++++++++++++- .../src/local/thread_history/realtime.rs | 2 +- .../src/local/thread_history/search.rs | 2 +- 11 files changed, 152 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 841bde32dd280ecbd2c4aec0c8db9788520505eb..483b244c01a55d8cfb37e130920a81153630076f 100644 GIT binary patch delta 122440 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

jbgTZa?}(<<34@eAmg%HVosw$ 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

~P*{QOX@833?6=q#Einb;7Q`}A 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%0ALLr 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 diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index b31dfdf0936b..1279bb8661c1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -624,7 +624,7 @@ pub struct ThreadForkParams { #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub ephemeral: bool, /// Persist the fork as an attached thread of its resolved source, hidden from ordinary - /// lists. The source cannot be deleted while attached threads exist. Requires local storage; + /// lists. The source cannot be deleted while attached threads exist. Requires a paginated source and local storage; /// incompatible with ephemeral and deferGoalContinuation. Omitted or false creates an ordinary fork. #[experimental("thread/fork.attachToSource")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index f5f0665e4e67..64f8bd39e8fd 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -4514,7 +4514,8 @@ impl ThreadRequestProcessor { }; restored_token_usage_turn_id(response_history.get_rollout_items(), turns) }) - .filter(|turn_id| !turn_id.is_empty()); + .filter(|turn_id| !turn_id.is_empty()) + .filter(|_| !paginated_resume || thread.forked_from_id.is_none()); if redact_resume_payloads { redact_thread_resume_payloads(&mut thread.turns); if let Some(initial_turns_page) = initial_turns_page.as_mut() { @@ -4858,7 +4859,8 @@ impl ThreadRequestProcessor { }); restored_token_usage_turn_id(history, turns) }) - .filter(|turn_id| !turn_id.is_empty()); + .filter(|turn_id| !turn_id.is_empty()) + .filter(|_| !paginated_resume || thread_summary.forked_from_id.is_none()); let paginated_initial_turns_page_with_active_slot = if paginated_resume { match params.initial_turns_page.as_ref() { Some(params) @@ -5391,6 +5393,11 @@ impl ThreadRequestProcessor { None }; let paginated_source = matches!(source_thread.history_mode, ThreadHistoryMode::Paginated); + if attach_to_source && !paginated_source { + return Err(invalid_request( + "attachToSource requires a paginated parent", + )); + } if last_turn_id.is_some() && before_turn_id.is_some() { return Err(invalid_request( "`beforeTurnId` cannot be combined with `lastTurnId`", @@ -5839,6 +5846,8 @@ impl ThreadRequestProcessor { thread.turns.as_slice(), )); } + let token_usage_turn_id = + token_usage_turn_id.filter(|id| thread.turns.iter().any(|turn| turn.id == *id)); if let Some(name) = source_thread_name { set_thread_name_from_title(&mut thread, name); } diff --git a/codex-rs/app-server/src/request_processors/token_usage_replay.rs b/codex-rs/app-server/src/request_processors/token_usage_replay.rs index c6d21f4cfe53..212400a826f1 100644 --- a/codex-rs/app-server/src/request_processors/token_usage_replay.rs +++ b/codex-rs/app-server/src/request_processors/token_usage_replay.rs @@ -39,6 +39,9 @@ pub(super) async fn send_thread_token_usage_update_to_connection( conversation: &CodexThread, token_usage_turn_id: String, ) { + if token_usage_turn_id.is_empty() { + return; + } let Some(info) = conversation.token_usage_info().await else { return; }; diff --git a/codex-rs/app-server/tests/suite/v2/attached_thread_tests.rs b/codex-rs/app-server/tests/suite/v2/attached_thread_tests.rs index 5f1b05327634..08c95a748be2 100644 --- a/codex-rs/app-server/tests/suite/v2/attached_thread_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/attached_thread_tests.rs @@ -10,10 +10,10 @@ use codex_app_server_protocol::ThreadDeleteParams; use codex_app_server_protocol::ThreadDeleteResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; -use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -32,24 +32,16 @@ enum Recovery { Corrupt, } -#[test_case(ThreadHistoryMode::Legacy, Recovery::Restart; "legacy restart")] -#[test_case(ThreadHistoryMode::Paginated, Recovery::Restart; "paginated restart")] -#[test_case(ThreadHistoryMode::Legacy, Recovery::Missing; "legacy missing database")] -#[test_case(ThreadHistoryMode::Paginated, Recovery::Corrupt; "paginated corrupt database")] +#[test_case(Recovery::Restart; "paginated restart")] +#[test_case(Recovery::Missing; "paginated missing database")] +#[test_case(Recovery::Corrupt; "paginated corrupt database")] #[tokio::test] -async fn saved_attached_thread_upgrade_restart_and_delete( - mode: ThreadHistoryMode, - recovery: Recovery, -) -> Result<()> { +async fn saved_attached_thread_upgrade_restart_and_delete(recovery: Recovery) -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(home.path())?; // Both parents were persisted before this app-server (and its SQLite migration) starts. - let create_rollout = match mode { - ThreadHistoryMode::Legacy => create_fake_rollout, - ThreadHistoryMode::Paginated => create_fake_paginated_rollout, - }; - let parent = create_rollout( + let parent = create_fake_paginated_rollout( home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", @@ -57,7 +49,7 @@ async fn saved_attached_thread_upgrade_restart_and_delete( Some("mock_provider"), /*git_info*/ None, )?; - let other = create_rollout( + let other = create_fake_paginated_rollout( home.path(), "2025-01-06T12-00-00", "2025-01-06T12:00:00Z", @@ -179,13 +171,14 @@ async fn saved_attached_thread_upgrade_restart_and_delete( .send_raw_request( "thread/resume", Some(json!({ - "threadId": children[0], "excludeTurns": true, + "threadId": children[0], "developerInstructions": "Follow BIRCH-849." })), ) .await?; let resumed: ThreadResumeResponse = mcp.read_response(id).await?; assert_eq!(resumed.thread.id, children[0]); + assert!(resumed.thread.turns.is_empty()); // Ordinary forks do not inherit attachment. let id = mcp .send_thread_fork_request(ThreadForkParams { @@ -218,7 +211,7 @@ async fn saved_attached_thread_upgrade_restart_and_delete( ..Default::default() }) .await?; - let _: TurnStartResponse = mcp.read_response(id).await?; + let started: TurnStartResponse = mcp.read_response(id).await?; mcp.read_stream_until_notification_message("turn/completed") .await?; let requests = server.received_requests().await.expect("response requests"); @@ -232,6 +225,19 @@ async fn saved_attached_thread_upgrade_restart_and_delete( input.contains("BIRCH-849"), "resume applies client instructions" ); + assert!( + input.contains("BLUE HERON 72"), + "model recalls the parent after restart" + ); + let id = mcp + .send_raw_request( + "thread/read", + Some(json!({"threadId": children[0], "includeTurns": true})), + ) + .await?; + let read: ThreadReadResponse = mcp.read_response(id).await?; + assert_eq!(read.thread.turns.len(), 1); + assert_eq!(read.thread.turns[0].id, started.turn.id); let id = mcp .send_raw_request("thread/resume", Some(json!({"threadId": parent}))) .await?; @@ -296,6 +302,11 @@ async fn saved_attached_thread_rejects_conflicting_fork_and_discovery_filters() .build_initialized() .await?; for (method, params, message) in [ + ( + "thread/fork", + json!({"threadId": parent, "attachToSource": true}), + "attachToSource requires a paginated parent", + ), ( "thread/fork", json!({"threadId": parent, "attachToSource": true, "ephemeral": true}), diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 146cadfda385..355f25ba846a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -1860,18 +1860,13 @@ async fn thread_fork_warns_for_paginated_full_history_hydration() -> Result<()> Ok(()) } +#[test_case::test_case(MultiAgentVersion::V1, false; "ordinary v1")] +#[test_case::test_case(MultiAgentVersion::V2, false; "ordinary v2")] +#[test_case::test_case(MultiAgentVersion::V1, true; "attached")] #[tokio::test] -async fn thread_fork_freezes_active_paginated_turn_as_interrupted() -> Result<()> { - assert_thread_fork_freezes_active_paginated_turn_as_interrupted(MultiAgentVersion::V1).await -} - -#[tokio::test] -async fn thread_fork_persists_developer_interruption_marker_for_multi_agent_v2() -> Result<()> { - assert_thread_fork_freezes_active_paginated_turn_as_interrupted(MultiAgentVersion::V2).await -} - -async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( +async fn thread_fork_freezes_active_paginated_turn_as_interrupted( multi_agent_version: MultiAgentVersion, + attach_to_source: bool, ) -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; @@ -1995,6 +1990,7 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( params: ThreadForkParams { thread_id: source_thread_id.clone(), thread_source, + attach_to_source, ..Default::default() }, }) @@ -2108,6 +2104,25 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( }, }) .await?; + if attach_to_source { + assert!(turns.is_empty()); + for (method, field) in [ + ("thread/revert", "beforeTurnId"), + ("thread/fork", "lastTurnId"), + ] { + let id = mcp + .send_raw_request( + method, + Some(json!({"threadId": forked_thread_id, (field): "active-turn"})), + ) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(id)) + .await?; + assert!(error.error.message.contains("turn not found: active-turn")); + } + return Ok(()); + } assert_eq!(turns.len(), 1); assert_eq!(turns[0].id, "active-turn"); assert_eq!(turns[0].root_turn_id.as_deref(), Some("causal-root")); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 060522cdb720..2b6946e1645a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -4302,6 +4302,38 @@ async fn cold_paginated_resume_restores_usage_without_loading_turns() -> Result< timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(turns_id)).await??; assert_eq!(notification.turn_id, turns.data[0].id); + let id = app_server + .send_raw_request( + "thread/fork", + Some(json!({"threadId": notification.thread_id, "attachToSource": true})), + ) + .await?; + let child: ThreadForkResponse = app_server.read_response(id).await?; + app_server.shutdown_gracefully().await?; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + for (method, params) in [ + ("thread/resume", json!({"threadId": child.thread.id})), + ("thread/resume", json!({"threadId": child.thread.id})), + ("thread/fork", json!({"threadId": child.thread.id})), + ] { + let id = app_server.send_raw_request(method, Some(params)).await?; + let _: serde_json::Value = app_server.read_response(id).await?; + } + app_server + .read_stream_until_notification_message("thread/started") + .await?; + assert!( + app_server + .pending_notification_methods() + .iter() + .all(|method| method != "thread/tokenUsage/updated"), + "an empty attached thread must not replay its parent's usage" + ); + Ok(()) } diff --git a/codex-rs/thread-store/src/local/paginated_fork.rs b/codex-rs/thread-store/src/local/paginated_fork.rs index 0b1409126776..09a3eede89a1 100644 --- a/codex-rs/thread-store/src/local/paginated_fork.rs +++ b/codex-rs/thread-store/src/local/paginated_fork.rs @@ -111,7 +111,10 @@ pub(super) async fn history_base_at_boundary( let position = match boundary { ForkBoundary::Latest => latest_position, ForkBoundary::ThroughTurn(turn_id) => { - let row = find_visible_turn(pool, lineage, turn_id.as_str()).await?; + let visible = store + .resolve_rollout_lineage_for_history_page(thread_id) + .await?; + let row = find_visible_turn(pool, &visible, turn_id.as_str()).await?; if row.status == "inProgress" { return Err(ThreadStoreError::InvalidRequest { message: format!("lastTurnId '{turn_id}' identifies an in-progress turn"), @@ -134,7 +137,10 @@ pub(super) async fn history_base_at_boundary( } } ForkBoundary::BeforeTurn(turn_id) => { - let row = find_source_turn(pool, lineage, turn_id.as_str()).await?; + let visible = store + .resolve_rollout_lineage_for_history_page(thread_id) + .await?; + let row = find_source_turn(pool, &visible, turn_id.as_str()).await?; if row.rollout_end_ordinal == Some(row.rollout_ordinal) { return Err(ThreadStoreError::InvalidRequest { message: format!("turn {turn_id} does not have a persisted start boundary"), diff --git a/codex-rs/thread-store/src/local/rollout_lineage.rs b/codex-rs/thread-store/src/local/rollout_lineage.rs index 3008f3751bd2..fdde832e0af6 100644 --- a/codex-rs/thread-store/src/local/rollout_lineage.rs +++ b/codex-rs/thread-store/src/local/rollout_lineage.rs @@ -4,9 +4,12 @@ use std::path::Path; use std::path::PathBuf; use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::HistoryPosition; use codex_protocol::protocol::ThreadHistoryMode; +use codex_rollout::RolloutItem; + use super::LocalThreadStore; use super::thread_rollout_resolver; use crate::ThreadStoreError; @@ -205,6 +208,20 @@ impl LocalThreadStore { })?, None => 1, }; + // Reverts can point to earlier records of the same child. Stop at the original parent bookmark. + let at_parent = matches!(mode, LineageResolutionMode::HistoryPage) + && meta.meta.attached_thread + && meta.meta.history_base.is_some_and(|base| { + codex_rollout::forked_from_ordinal_exclusive(&meta.meta, Some(&rollout_path)) + .is_some_and(|end| base.end_ordinal_exclusive <= end) + }); + let start_ordinal = if at_parent { + attached_history_start(&rollout_path, start_ordinal) + .await + .map_err(|e| malformed_lineage(requested_thread_id, &e.to_string()))? + } else { + start_ordinal + }; segments.push(RolloutLineageSegment { rollout_id, rollout_path, @@ -212,7 +229,7 @@ impl LocalThreadStore { end, }); - let Some(base) = meta.meta.history_base else { + let Some(base) = meta.meta.history_base.filter(|_| !at_parent) else { break; }; if matches!(mode, LineageResolutionMode::HistoryPage) @@ -235,6 +252,30 @@ impl LocalThreadStore { } } +/// A mid-turn fork starts with its header, settings, optional model guidance, and parent abort. +/// Stop before the first child event; a real child interruption follows its own turn start. +async fn attached_history_start(path: &Path, start: u64) -> std::io::Result { + let mut reader = codex_rollout::open_rollout_line_reader(path).await?; + for _ in 0..4 { + let Some(line) = reader.next_line().await? else { + break; + }; + let Ok(line) = codex_rollout::parse_rollout_line(&line) else { + break; + }; + match line.item { + RolloutItem::SessionMeta(_) + | RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(_)) + | RolloutItem::ResponseItem(_) => {} + RolloutItem::EventMsg(EventMsg::TurnAborted(_)) => { + return Ok(line.ordinal.map_or(start, |n| n.saturating_add(1))); + } + _ => break, + } + } + Ok(start) +} + async fn resolve_rollout_path_by_id( store: &LocalThreadStore, rollout_id: ThreadId, diff --git a/codex-rs/thread-store/src/local/thread_history/realtime.rs b/codex-rs/thread-store/src/local/thread_history/realtime.rs index af0209fd478e..cfd3a8ebdefb 100644 --- a/codex-rs/thread-store/src/local/thread_history/realtime.rs +++ b/codex-rs/thread-store/src/local/thread_history/realtime.rs @@ -53,7 +53,7 @@ pub(in crate::local) async fn list_timeline( validate_page_size(params.page_size)?; let lineage = store - .resolve_rollout_lineage(params.thread_id, /*initial_path*/ None) + .resolve_rollout_lineage_for_history_page(params.thread_id) .await?; let pool = store.thread_history_db().await?; let cursor = params diff --git a/codex-rs/thread-store/src/local/thread_history/search.rs b/codex-rs/thread-store/src/local/thread_history/search.rs index 6aeb6d576acd..e3896447a9ce 100644 --- a/codex-rs/thread-store/src/local/thread_history/search.rs +++ b/codex-rs/thread-store/src/local/thread_history/search.rs @@ -75,7 +75,7 @@ pub(in crate::local) async fn search_thread_occurrences( )?; let matcher = LiteralMatcher::new(params.search_term.as_str()); let lineage = store - .resolve_rollout_lineage(params.thread_id, /*initial_path*/ None) + .resolve_rollout_lineage_for_history_page(params.thread_id) .await?; let cursor_segment = cursor .as_ref() From eb3572f9d7ed2ed6892c86fdca037f7c6d1e9d49 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 26 Oct 2026 16:32:26 +0000 Subject: [PATCH 071/287] Avoid unnecessary connector directory work for tool suggestions (#57257) ## Why Tool suggestions loaded and cloned the cached connector directory even when no candidates remained after excluding accessible apps. ## What changed - Filter out accessible connectors before loading the directory, skipping the lookup when no candidates remain. - Share cached directory entries through `Arc<[AppInfo]>` and clone only matching entries for suggestions. - Preserve placeholder suggestions for candidate connectors missing from the directory. ## Testing Add coverage for skipping directory lookups with no remaining candidates, sharing cached storage, and preserving directory metadata and missing-connector fallbacks while excluding accessible apps, including disabled ones. GitOrigin-RevId: acaf3e1564fb4e2b4f805cffe1d7db8e40d5fe3d --- codex-rs/chatgpt/src/connectors.rs | 2 +- codex-rs/connectors/src/filter.rs | 121 ++++++++++-------- codex-rs/connectors/src/lib.rs | 33 ++--- codex-rs/core/src/connectors.rs | 19 ++- .../tests/suite/request_plugin_install.rs | 68 +++++++++- 5 files changed, 157 insertions(+), 86 deletions(-) diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 6b30a7393640..51a5fbe609fb 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -90,7 +90,7 @@ pub async fn list_cached_all_connectors( let cache_context = connector_directory_cache_context(config, &auth); let connectors = codex_connectors::cached_directory_connectors(&cache_context)?; Some(merge_directory_and_plugin_connectors( - connectors, + connectors.to_vec(), plugin_apps, )) } diff --git a/codex-rs/connectors/src/filter.rs b/codex-rs/connectors/src/filter.rs index 3fcabd6fb6fc..7089b7e3c24d 100644 --- a/codex-rs/connectors/src/filter.rs +++ b/codex-rs/connectors/src/filter.rs @@ -1,23 +1,40 @@ use std::collections::HashSet; +use std::sync::Arc; use crate::AppInfo; +use crate::merge::plugin_connector_to_app_info; -pub fn filter_tool_suggest_discoverable_connectors( - directory_connectors: Vec, +/// Load the directory only when a candidate still needs to be suggested. +pub async fn filter_tool_suggest_discoverable_connectors( + directory_connectors: impl Future>, accessible_connectors: &[AppInfo], - discoverable_connector_ids: &HashSet, + mut discoverable_connector_ids: HashSet, ) -> Vec { - let accessible_connector_ids: HashSet<&str> = accessible_connectors - .iter() - .filter(|connector| connector.is_accessible) - .map(|connector| connector.id.as_str()) - .collect(); + for connector in accessible_connectors { + if connector.is_accessible { + discoverable_connector_ids.remove(&connector.id); + } + } + if discoverable_connector_ids.is_empty() { + return Vec::new(); + } - let mut connectors = directory_connectors - .into_iter() - .filter(|connector| !accessible_connector_ids.contains(connector.id.as_str())) - .filter(|connector| discoverable_connector_ids.contains(connector.id.as_str())) - .collect::>(); + let mut connectors = Vec::with_capacity(discoverable_connector_ids.len()); + connectors.extend( + directory_connectors + .await + .iter() + .filter(|connector| discoverable_connector_ids.contains(&connector.id)) + .cloned(), + ); + for connector in &connectors { + discoverable_connector_ids.remove(&connector.id); + } + connectors.extend( + discoverable_connector_ids + .into_iter() + .map(plugin_connector_to_app_info), + ); connectors.sort_by(|left, right| { left.name .cmp(&right.name) @@ -61,17 +78,18 @@ mod tests { } } - #[test] - fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { + #[tokio::test] + async fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() + { let filtered = filter_tool_suggest_discoverable_connectors( - vec![ + std::future::ready(Arc::from([ named_app( "connector_2128aebfecb84f64a069897515042a44", "Google Calendar", ), named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), named_app("connector_other", "Other"), - ], + ])), &[AppInfo { is_accessible: true, ..named_app( @@ -79,51 +97,48 @@ mod tests { "Google Calendar", ) }], - &HashSet::from([ + HashSet::from([ "connector_2128aebfecb84f64a069897515042a44".to_string(), "connector_68df038e0ba48191908c8434991bbac2".to_string(), + "connector_missing".to_string(), ]), - ); + ) + .await; assert_eq!( filtered, - vec![named_app( - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail", - )] - ); - } - - #[test] - fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() { - let filtered = filter_tool_suggest_discoverable_connectors( vec![ - named_app( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - ), named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), - ], - &[ - AppInfo { - is_accessible: true, - ..named_app( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - ) - }, - AppInfo { - is_accessible: true, - is_enabled: false, - ..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail") - }, - ], - &HashSet::from([ - "connector_2128aebfecb84f64a069897515042a44".to_string(), - "connector_68df038e0ba48191908c8434991bbac2".to_string(), - ]), + plugin_connector_to_app_info("connector_missing".to_string()), + ] ); + } - assert_eq!(filtered, Vec::::new()); + #[tokio::test] + async fn skips_directory_when_no_connectors_can_be_suggested() { + let accessible = [ + AppInfo { + is_accessible: true, + ..app("calendar") + }, + AppInfo { + is_accessible: true, + is_enabled: false, + ..app("gmail") + }, + ]; + for candidates in [ + HashSet::new(), + HashSet::from(["calendar".to_string(), "gmail".to_string()]), + ] { + let filtered = filter_tool_suggest_discoverable_connectors( + async { panic!("unexpected connector directory lookup") }, + &accessible, + candidates, + ) + .await; + + assert_eq!(filtered, Vec::::new()); + } } } diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs index ec8b19c9217b..ae80ef4bf7ac 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::future::Future; +use std::sync::Arc; use std::sync::LazyLock; use std::sync::Mutex as StdMutex; use std::time::Duration; @@ -85,7 +86,7 @@ impl ConnectorDirectoryCacheKey { struct CachedConnectorDirectory { key: ConnectorDirectoryCacheKey, expires_at: Instant, - connectors: Vec, + connectors: Arc<[AppInfo]>, } static CONNECTOR_DIRECTORY_CACHE: LazyLock>> = @@ -122,7 +123,7 @@ pub struct DirectoryApp { pub fn cached_directory_connectors( cache_context: &ConnectorDirectoryCacheContext, -) -> Option> { +) -> Option> { if let Some(cached_connectors) = cached_directory_connectors_in_memory(&cache_context.cache_key) { return Some(cached_connectors); @@ -133,9 +134,10 @@ pub fn cached_directory_connectors( else { return None; }; + let connectors = Arc::from(connectors); write_cached_directory_connectors_in_memory( cache_context.cache_key.clone(), - &connectors, + Arc::clone(&connectors), Duration::ZERO, ); Some(connectors) @@ -143,25 +145,25 @@ pub fn cached_directory_connectors( fn cached_directory_connectors_in_memory( cache_key: &ConnectorDirectoryCacheKey, -) -> Option> { +) -> Option> { let cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); cache_guard .as_ref() .filter(|cached| cached.key == *cache_key) - .map(|cached| cached.connectors.clone()) + .map(|cached| Arc::clone(&cached.connectors)) } fn unexpired_directory_connectors_in_memory( cache_key: &ConnectorDirectoryCacheKey, -) -> Option> { +) -> Option> { let cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let cached = cache_guard.as_ref()?; if cached.key == *cache_key && Instant::now() < cached.expires_at { - return Some(cached.connectors.clone()); + return Some(Arc::clone(&cached.connectors)); } None } @@ -180,7 +182,7 @@ where && let Some(cached_connectors) = unexpired_directory_connectors_in_memory(&cache_context.cache_key) { - return Ok(cached_connectors); + return Ok(cached_connectors.to_vec()); } let apps = if is_workspace_account { @@ -235,7 +237,7 @@ fn write_cached_directory_connectors( ) { write_cached_directory_connectors_in_memory( cache_context.cache_key.clone(), - connectors, + Arc::from(connectors), CONNECTORS_CACHE_TTL, ); directory_cache::write_cached_directory_connectors_to_disk(cache_context, connectors); @@ -243,7 +245,7 @@ fn write_cached_directory_connectors( fn write_cached_directory_connectors_in_memory( cache_key: ConnectorDirectoryCacheKey, - connectors: &[AppInfo], + connectors: Arc<[AppInfo]>, ttl: Duration, ) { let mut cache_guard = CONNECTOR_DIRECTORY_CACHE @@ -252,7 +254,7 @@ fn write_cached_directory_connectors_in_memory( *cache_guard = Some(CachedConnectorDirectory { key: cache_key, expires_at: Instant::now() + ttl, - connectors: connectors.to_vec(), + connectors, }); } @@ -526,7 +528,6 @@ fn normalize_connector_value(value: Option<&str>) -> Option { mod tests { use super::*; use pretty_assertions::assert_eq; - use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -849,7 +850,9 @@ mod tests { let second = cached_directory_connectors(&cache_context).expect("disk cache should load"); assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(first, second); + assert_eq!(first.as_slice(), second.as_ref()); + let cached = cached_directory_connectors(&cache_context).expect("memory cache should hit"); + assert!(Arc::ptr_eq(&second, &cached)); Ok(()) } @@ -891,8 +894,8 @@ mod tests { &cached_expected.id, )); assert_eq!( - cached_directory_connectors(&cache_context), - Some(vec![cached_expected]) + cached_directory_connectors(&cache_context).as_deref(), + Some([cached_expected].as_slice()) ); let refreshed_calls = Arc::clone(&calls); diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 9b1a34a572d7..59b661f1db16 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -89,16 +89,13 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { let connector_ids = tool_suggest_connector_ids(config, loaded_plugin_app_connector_ids); - let directory_connectors = codex_connectors::merge::merge_plugin_connectors( - cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await, - connector_ids.iter().cloned(), - ); let discoverable_connectors = codex_connectors::filter::filter_tool_suggest_discoverable_connectors( - directory_connectors, + cached_directory_connectors_for_tool_suggest_with_auth(config, auth), accessible_connectors, - &connector_ids, + connector_ids, ) + .await .into_iter() .map(DiscoverableTool::from); let discoverable_plugins = list_tool_suggest_discoverable_plugins( @@ -423,9 +420,9 @@ fn tool_suggest_connector_ids( async fn cached_directory_connectors_for_tool_suggest_with_auth( config: &Config, auth: Option<&CodexAuth>, -) -> Vec { +) -> Arc<[AppInfo]> { if !config.features.enabled(Feature::Apps) { - return Vec::new(); + return Arc::default(); } let loaded_auth; @@ -435,18 +432,18 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth( let Ok(auth_manager) = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await else { - return Vec::new(); + return Arc::default(); }; loaded_auth = auth_manager.auth().await; loaded_auth.as_ref() }; let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { - return Vec::new(); + return Arc::default(); }; let account_id = match auth.get_account_id() { Some(account_id) if !account_id.is_empty() => account_id, - _ => return Vec::new(), + _ => return Arc::default(), }; let is_workspace_account = auth.is_workspace_account(); let cache_context = ConnectorDirectoryCacheContext::new( diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index 2d8766b4fdc4..8bb265076607 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -5,6 +5,8 @@ use anyhow::Result; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::ToolSuggestDiscoverableType; +use codex_connectors::ConnectorDirectoryCacheContext; +use codex_connectors::ConnectorDirectoryCacheKey; use codex_core::NewThread; use codex_core::StartThreadOptions; use codex_core::TurnInputRequest; @@ -759,7 +761,43 @@ async fn unavailable_recommendations_preserve_legacy_workflow( ], ) .await; - let test = build_test(&server, &apps_server).await?; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let mut builder = test_codex() + .with_auth(auth.clone()) + .with_pre_build_hook(|home| { + std::fs::write( + home.join("config.toml"), + "[apps.calendar]\nenabled = false\n", + ) + .expect("write disabled calendar app"); + }) + .with_config(move |config| { + configure_apps_without_search_tool(config, &apps_server.chatgpt_base_url); + config.tool_suggest.discoverables.extend( + [CALENDAR_CONNECTOR_ID, "connector_missing"].map(|id| ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Connector, + id: id.to_string(), + }), + ); + }); + let test = builder.build_with_auto_env(&server).await?; + let cache_key = ConnectorDirectoryCacheKey::new( + server.uri(), + auth.get_account_id(), + auth.get_chatgpt_user_id(), + auth.is_workspace_account(), + ); + let client = codex_login::default_client::create_client(); + codex_connectors::list_all_connectors_with_options( + ConnectorDirectoryCacheContext::new(test.home.path().to_path_buf(), cache_key), + auth.is_workspace_account(), + /*force_refetch*/ true, + |path| { + let request = client.get(format!("{}{path}", server.uri())); + async move { Ok(request.send().await?.json().await?) } + }, + ) + .await?; test.submit_turn_with_approval_and_permission_profile( "list tools", AskForApproval::Never, @@ -781,11 +819,29 @@ async fn unavailable_recommendations_preserve_legacy_workflow( .function_call_output_text(call_id) .expect("list tool output"); let output: Value = serde_json::from_str(&output)?; - assert!(output["tools"].as_array().is_some_and(|tools| { - tools - .iter() - .any(|tool| tool["id"] == DISCOVERABLE_GMAIL_ID && tool["tool_type"] == "connector") - })); + assert_eq!( + output["tools"], + json!([ + { + "id": DISCOVERABLE_GMAIL_ID, + "name": "Gmail", + "description": "Find and summarize email threads.", + "tool_type": "connector", + "has_skills": false, + "mcp_server_names": [], + "app_connector_ids": [] + }, + { + "id": "connector_missing", + "name": "connector_missing", + "description": null, + "tool_type": "connector", + "has_skills": false, + "mcp_server_names": [], + "app_connector_ids": [] + } + ]) + ); Ok(()) } From a8bca62978f9fe1f44f261a77eaa6374a6860a2c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 26 Oct 2026 16:33:31 +0000 Subject: [PATCH 072/287] Reduce remote plugin catalog cache probing overhead (#57258) ## Why Cache presence and freshness checks deserialize full plugin records even when callers only need to decide whether a cached catalog is available. ## What changed - Probe cache availability without deserializing plugin records, while still requiring a valid catalog envelope and a `plugins` array. - Let `PreferFreshCache` reject stale catalogs from a 256-byte prefix when the header provides enough information; fall back to a full read otherwise. - Limit discovery cache probes to the user and workspace scopes that need them, skipping the global cache probe. - Serialize cached plugin slices directly without cloning them. ## Testing Add coverage for malformed cache headers and payloads, reordered fields, stale-prefix rejection, and deferring plugin-record validation until a full catalog load. GitOrigin-RevId: 1e5ec95917f38c84bba30dbd3bb8c2f91cc7e3a8 --- codex-rs/core-plugins/src/manager.rs | 9 +- codex-rs/core-plugins/src/remote.rs | 39 +++-- .../core-plugins/src/remote/catalog_cache.rs | 161 ++++++++++++++++-- .../src/remote/catalog_cache_tests.rs | 104 +++++++++-- codex-rs/core-plugins/src/remote_tests.rs | 12 +- 5 files changed, 275 insertions(+), 50 deletions(-) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 4c299c32fc4a..ef600e8774f2 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -2983,15 +2983,20 @@ impl PluginsManager { if !prefetch_discovery { return; } + let scopes_to_probe: &[RemotePluginScope] = + if config_for_remote_sync.remote_plugin_enabled { + &[RemotePluginScope::User, RemotePluginScope::Workspace] + } else { + &[RemotePluginScope::Workspace] + }; let mut scopes = crate::remote::cached_remote_plugin_catalog_scopes( manager.codex_home.as_path(), &remote_plugin_service_config(&config_for_remote_sync), auth.as_ref(), + scopes_to_probe, ); if config_for_remote_sync.remote_plugin_enabled { scopes.insert(RemotePluginScope::Global); - } else { - scopes.retain(|scope| *scope == RemotePluginScope::Workspace); } manager.maybe_start_remote_catalog_cache_refresh( &config_for_remote_sync, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 2e3e4ae00aa6..d51d40a12224 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -541,8 +541,6 @@ enum RemoteInstalledPluginScope { } impl RemotePluginScope { - const CATALOG_CACHE_SCOPES: [Self; 3] = [Self::Global, Self::User, Self::Workspace]; - fn api_value(self) -> &'static str { match self { Self::Global => "GLOBAL", @@ -1155,32 +1153,35 @@ pub(crate) fn has_fresh_cached_remote_plugin_catalog( let Ok(auth) = ensure_chatgpt_auth(auth) else { return false; }; - catalog_cache::load_cached_directory_plugins( - codex_home, config, auth, scope, /*collection*/ None, + catalog_cache::has_cached_directory_plugins( + codex_home, + config, + auth, + scope, + RemotePluginCatalogCacheMode::PreferFreshCache, ) - .is_some_and(|cached| { - matches!( - cached.freshness, - catalog_cache::RemotePluginCatalogCacheFreshness::Fresh - ) - }) } pub(crate) fn cached_remote_plugin_catalog_scopes( codex_home: &Path, config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, + scopes: &[RemotePluginScope], ) -> BTreeSet { let Ok(auth) = ensure_chatgpt_auth(auth) else { return BTreeSet::new(); }; - RemotePluginScope::CATALOG_CACHE_SCOPES - .into_iter() + scopes + .iter() + .copied() .filter(|scope| { - catalog_cache::load_cached_directory_plugins( - codex_home, config, auth, *scope, /*collection*/ None, + catalog_cache::has_cached_directory_plugins( + codex_home, + config, + auth, + *scope, + RemotePluginCatalogCacheMode::PreferCache, ) - .is_some() }) .collect() } @@ -1196,6 +1197,7 @@ pub fn cached_global_remote_discoverable_plugins( auth, RemotePluginScope::Global, /*collection*/ None, + RemotePluginCatalogCacheMode::PreferCache, ) .map(|cached| cached.plugins) .unwrap_or_default() @@ -2097,13 +2099,10 @@ async fn fetch_directory_plugins_for_scope_with_cache( collection: Option<&str>, cache_mode: RemotePluginCatalogCacheMode, ) -> Result { - if cache_mode != RemotePluginCatalogCacheMode::ForceRefetch - && let Some(codex_home) = codex_home + if let Some(codex_home) = codex_home && let Some(cached) = catalog_cache::load_cached_directory_plugins( - codex_home, config, auth, scope, collection, + codex_home, config, auth, scope, collection, cache_mode, ) - && (cache_mode == RemotePluginCatalogCacheMode::PreferCache - || cached.freshness == catalog_cache::RemotePluginCatalogCacheFreshness::Fresh) { return Ok(DirectoryPluginsFetchOutcome { plugins: cached.plugins, diff --git a/codex-rs/core-plugins/src/remote/catalog_cache.rs b/codex-rs/core-plugins/src/remote/catalog_cache.rs index cf78330e2593..cd63578972b9 100644 --- a/codex-rs/core-plugins/src/remote/catalog_cache.rs +++ b/codex-rs/core-plugins/src/remote/catalog_cache.rs @@ -1,3 +1,4 @@ +use super::RemotePluginCatalogCacheMode; use super::RemotePluginDirectoryItem; use super::RemotePluginScope; use super::RemotePluginServiceConfig; @@ -5,7 +6,18 @@ use chrono::DateTime; use chrono::Utc; use codex_login::CodexAuth; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::de::DeserializeOwned; +use serde::de::DeserializeSeed; +use serde::de::IgnoredAny; +use serde::de::MapAccess; +use serde::de::SeqAccess; +use serde::de::Visitor; +use std::fmt; +use std::fs::File; +use std::io; +use std::io::Read; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -58,12 +70,12 @@ impl RemotePluginCatalogCacheKey { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RemotePluginCatalogDiskCache { +#[derive(Serialize, Deserialize)] +struct RemotePluginCatalogDiskCache { schema_version: u8, #[serde(default, skip_serializing_if = "Option::is_none")] fetched_at: Option>, - plugins: Vec, + plugins: Plugins, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -72,23 +84,40 @@ pub(super) enum RemotePluginCatalogCacheFreshness { Stale, } -#[derive(Debug, Clone)] -pub(super) struct CachedDirectoryPlugins { - pub plugins: Vec, +pub(super) struct CachedDirectoryPlugins { + pub plugins: Vec, pub freshness: RemotePluginCatalogCacheFreshness, } -pub(crate) fn load_cached_directory_plugins( +pub(super) fn has_cached_directory_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + cache_mode: RemotePluginCatalogCacheMode, +) -> bool { + // Ignore each entry so Serde still requires `plugins` to be an array. + load_cached_directory_plugins::( + codex_home, config, auth, scope, /*collection*/ None, cache_mode, + ) + .is_some() +} + +pub(super) fn load_cached_directory_plugins( codex_home: &Path, config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, collection: Option<&str>, -) -> Option { + cache_mode: RemotePluginCatalogCacheMode, +) -> Option> { + if cache_mode == RemotePluginCatalogCacheMode::ForceRefetch { + return None; + } let cache_key = RemotePluginCatalogCacheKey::new(config, auth, scope, collection)?; let cache_path = cache_path(codex_home, &cache_key); - let bytes = match std::fs::read(&cache_path) { - Ok(bytes) => bytes, + let bytes = match read_catalog_bytes(&cache_path, cache_mode) { + Ok(bytes) => bytes?, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, Err(err) => { warn!( @@ -98,7 +127,7 @@ pub(crate) fn load_cached_directory_plugins( return None; } }; - let cache: RemotePluginCatalogDiskCache = match serde_json::from_slice(&bytes) { + let cache: RemotePluginCatalogDiskCache> = match serde_json::from_slice(&bytes) { Ok(cache) => cache, Err(err) => { warn!( @@ -116,6 +145,8 @@ pub(crate) fn load_cached_directory_plugins( let freshness = if is_fresh(cache.fetched_at, Utc::now()) { RemotePluginCatalogCacheFreshness::Fresh + } else if cache_mode == RemotePluginCatalogCacheMode::PreferFreshCache { + return None; } else { RemotePluginCatalogCacheFreshness::Stale }; @@ -125,6 +156,112 @@ pub(crate) fn load_cached_directory_plugins( }) } +fn read_catalog_bytes( + path: &Path, + cache_mode: RemotePluginCatalogCacheMode, +) -> io::Result>> { + let mut file = File::open(path)?; + let mut bytes = Vec::new(); + if cache_mode == RemotePluginCatalogCacheMode::PreferFreshCache { + const PREFIX_LEN: usize = 256; + bytes.reserve_exact(PREFIX_LEN); + file.by_ref() + .take(PREFIX_LEN as u64) + .read_to_end(&mut bytes)?; + if bytes.len() < PREFIX_LEN { + return Ok(Some(bytes)); + } + if CatalogHeader::is_stale_prefix(&bytes, Utc::now()) { + return Ok(None); + } + } + file.read_to_end(&mut bytes)?; + Ok(Some(bytes)) +} + +#[derive(Default)] +struct CatalogHeader { + schema_version: Option, + fetched_at: Option>, + metadata_before_plugins: bool, +} + +impl CatalogHeader { + fn is_stale_prefix(bytes: &[u8], now: DateTime) -> bool { + let mut header = Self::default(); + let mut json = serde_json::Deserializer::from_slice(bytes); + // A bounded read can end inside the payload. Only fully decoded fields count. + let parsed = json.deserialize_map(&mut header).and_then(|()| json.end()); + if parsed.is_err_and(|err| !err.is_eof()) { + return false; + } + header.metadata_before_plugins && !is_fresh(header.fetched_at, now) + } +} + +// Only the `plugins` field uses the seed. Serde calls `visit_seq` when it enters +// that array, so a missing or wrongly typed payload still goes through the full reader. +impl<'de> DeserializeSeed<'de> for &mut CatalogHeader { + type Value = (); + + fn deserialize>(self, deserializer: D) -> Result<(), D::Error> { + deserializer.deserialize_seq(self) + } +} + +impl<'de> Visitor<'de> for &mut CatalogHeader { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a plugin catalog object or its plugins array") + } + + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "snake_case")] + enum Field { + SchemaVersion, + FetchedAt, + Plugins, + #[serde(other)] + Other, + } + + let mut saw_plugins = false; + while let Some(field) = map.next_key()? { + match field { + Field::SchemaVersion if self.schema_version.is_none() => { + self.schema_version = Some(map.next_value()?); + } + Field::FetchedAt if self.fetched_at.is_none() => { + self.fetched_at = Some(map.next_value()?); + } + Field::Plugins if !saw_plugins => { + saw_plugins = true; + map.next_value_seed(&mut *self)?; + } + Field::SchemaVersion => { + return Err(serde::de::Error::duplicate_field("schema_version")); + } + Field::FetchedAt => return Err(serde::de::Error::duplicate_field("fetched_at")), + Field::Plugins => return Err(serde::de::Error::duplicate_field("plugins")), + Field::Other => { + map.next_value::()?; + } + } + } + Ok(()) + } + + fn visit_seq>(self, mut plugins: A) -> Result<(), A::Error> { + self.metadata_before_plugins = self.schema_version + == Some(REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION) + && self.fetched_at.is_some(); + while plugins.next_element::()?.is_some() {} + Ok(()) + } +} + pub(crate) fn write_cached_directory_plugins( codex_home: &Path, config: &RemotePluginServiceConfig, @@ -140,7 +277,7 @@ pub(crate) fn write_cached_directory_plugins( let Ok(contents) = serde_json::to_string_pretty(&RemotePluginCatalogDiskCache { schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION, fetched_at: Some(Utc::now()), - plugins: plugins.to_vec(), + plugins, }) else { return; }; diff --git a/codex-rs/core-plugins/src/remote/catalog_cache_tests.rs b/codex-rs/core-plugins/src/remote/catalog_cache_tests.rs index 8ccce5f3c09e..9c55d8d6490c 100644 --- a/codex-rs/core-plugins/src/remote/catalog_cache_tests.rs +++ b/codex-rs/core-plugins/src/remote/catalog_cache_tests.rs @@ -62,15 +62,16 @@ fn global_catalog_cache_reuses_legacy_cache_file() { "schema_version": REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION, "plugins": [], }); - let contents = serde_json::to_string_pretty(&legacy_cache).expect("serialize legacy cache"); - codex_utils_path::write_atomically(&legacy_cache_path, &contents).expect("write legacy cache"); + codex_utils_path::write_atomically(&legacy_cache_path, &legacy_cache.to_string()) + .expect("write legacy cache"); - let cached = load_cached_directory_plugins( + let cached = load_cached_directory_plugins::( codex_home.path(), &config, &auth, RemotePluginScope::Global, /*collection*/ None, + RemotePluginCatalogCacheMode::PreferCache, ) .expect("load legacy global cache"); assert!(cached.plugins.is_empty()); @@ -84,11 +85,90 @@ fn global_catalog_cache_reuses_legacy_cache_file() { /*collection*/ None, &[], ); - let refreshed_cache: RemotePluginCatalogDiskCache = serde_json::from_slice( - &std::fs::read(&legacy_cache_path).expect("read refreshed legacy cache"), - ) - .expect("parse refreshed legacy cache"); - assert!(refreshed_cache.fetched_at.is_some()); + assert!(has_cached_directory_plugins( + codex_home.path(), + &config, + &auth, + RemotePluginScope::Global, + RemotePluginCatalogCacheMode::PreferFreshCache, + )); +} + +#[test] +fn freshness_probe_defers_plugin_validation_until_full_read() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let config = RemotePluginServiceConfig::new( + "https://chatgpt.com/backend-api".to_string(), + crate::test_support::test_http_client_factory(), + /*product_sku*/ None, + ); + let auth = CodexAuth::Headers(AuthHeaders::new(http::HeaderMap::new())); + let scope = RemotePluginScope::Global; + let key = RemotePluginCatalogCacheKey::new(&config, &auth, scope, /*collection*/ None) + .expect("global cache key"); + let path = cache_path(codex_home.path(), &key); + let probe = |mode| has_cached_directory_plugins(codex_home.path(), &config, &auth, scope, mode); + let version = REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION; + let now = serde_json::to_string(&Utc::now()).expect("serialize timestamp"); + let expired_at = "2000-01-01T00:00:00Z"; + let padding = " ".repeat(512); + for contents in [ + serde_json::json!({"schema_version": version + 1, "plugins": []}).to_string(), + serde_json::json!({"schema_version": version}).to_string(), + serde_json::json!({"schema_version": version, "plugins": {}}).to_string(), + format!(r#"{{"schema_version":{version},"plugins":[{{"#), + format!(r#"{{"schema_version":{version},"fetched_at":{now},"plugins":[{padding}"#), + format!( + r#"{{"details":{{"fetched_at":"{expired_at}"}},"schema_version":{version},"plugins":[{padding}"# + ), + format!( + r#"{{"schema_version":{version},"fetched_at":"{expired_at}","fetched_at":{now},"plugins":[{padding}"# + ), + format!( + r#"{{"schema_version":{version},"fetched_at":"{expired_at}","plugins":{{}}}}{padding}"# + ), + ] { + codex_utils_path::write_atomically(&path, &contents).expect("write invalid cache"); + assert!( + !probe(RemotePluginCatalogCacheMode::PreferFreshCache), + "{contents}" + ); + assert!(!path.exists()); + } + + let expired = format!( + r#"{{"note":"[","fetched\u005fat":"{expired_at}","schema_version":{version},"plugins":[{padding}"# + ); + codex_utils_path::write_atomically(&path, &expired).expect("write expired cache"); + assert!(!probe(RemotePluginCatalogCacheMode::PreferFreshCache)); + assert!(path.exists()); + assert!(!probe(RemotePluginCatalogCacheMode::PreferCache)); + assert!(!path.exists()); + + let reordered = + format!(r#"{{"plugins":[{padding}],"schema_version":{version},"fetched_at":{now}}}"#); + codex_utils_path::write_atomically(&path, &reordered).expect("write reordered cache"); + assert!(probe(RemotePluginCatalogCacheMode::PreferFreshCache)); + + let contents = serde_json::json!({ + "schema_version": version, + "fetched_at": Utc::now(), + "plugins": [{}], + }); + codex_utils_path::write_atomically(&path, &contents.to_string()).expect("write invalid plugin"); + assert!(probe(RemotePluginCatalogCacheMode::PreferFreshCache)); + assert!( + load_cached_directory_plugins::( + codex_home.path(), + &config, + &auth, + scope, + /*collection*/ None, + RemotePluginCatalogCacheMode::PreferCache, + ) + .is_none() + ); + assert!(!path.exists()); } #[test] @@ -110,12 +190,13 @@ fn header_auth_does_not_cache_private_catalogs_without_a_stable_identity() { &[], ); assert!( - load_cached_directory_plugins( + load_cached_directory_plugins::( codex_home.path(), &config, &auth, RemotePluginScope::Global, /*collection*/ None, + RemotePluginCatalogCacheMode::PreferCache, ) .is_some() ); @@ -144,19 +225,20 @@ fn header_auth_does_not_cache_private_catalogs_without_a_stable_identity() { let insecure_cache = RemotePluginCatalogDiskCache { schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION, fetched_at: Some(Utc::now()), - plugins: Vec::new(), + plugins: Vec::::new(), }; let contents = serde_json::to_string_pretty(&insecure_cache).expect("serialize cache"); codex_utils_path::write_atomically(&insecure_cache_path, &contents) .expect("write insecure cache"); assert!( - load_cached_directory_plugins( + load_cached_directory_plugins::( codex_home.path(), &config, &auth, scope, /*collection*/ None, + RemotePluginCatalogCacheMode::PreferCache, ) .is_none() ); diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index 580baabc8552..ebf2769002a1 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -468,7 +468,8 @@ fn catalog_cache_invalidation_clears_global_collections_and_preserves_other_scop /*product_sku*/ None, ); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - for scope in [RemotePluginScope::Global, RemotePluginScope::Workspace] { + let scopes = [RemotePluginScope::Global, RemotePluginScope::Workspace]; + for scope in scopes { catalog_cache::write_cached_directory_plugins( codex_home.path(), &config, @@ -480,8 +481,8 @@ fn catalog_cache_invalidation_clears_global_collections_and_preserves_other_scop } assert_eq!( - cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth)), - BTreeSet::from([RemotePluginScope::Global, RemotePluginScope::Workspace]) + cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth), &scopes), + BTreeSet::from(scopes) ); catalog_cache::write_cached_directory_plugins( codex_home.path(), @@ -500,16 +501,17 @@ fn catalog_cache_invalidation_clears_global_collections_and_preserves_other_scop ); assert_eq!( - cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth)), + cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth), &scopes), BTreeSet::from([RemotePluginScope::Workspace]), ); assert!( - catalog_cache::load_cached_directory_plugins( + catalog_cache::load_cached_directory_plugins::( codex_home.path(), &config, &auth, RemotePluginScope::Global, Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY), + RemotePluginCatalogCacheMode::PreferCache, ) .is_none() ); From 81cbc26ee3c1beb903879bbaad1e3809aca18c4a Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Mon, 26 Oct 2026 16:34:13 +0000 Subject: [PATCH 073/287] Render Markdown in TUI async question choices (#57259) ## Why Async question titles support Markdown, but named choices display Markdown syntax as plain text. ## What changed Render named choices with Markdown styling and terminal hyperlinks, wrapping them to the available width after the numbered prefix. Use the rendered rows for height measurement and scrolling, and reuse layout measurements for the option tip. Share viewport-aware Markdown rendering between question titles and choices, preserving hyperlink destinations when splitting overwide tokens. ## Testing Extend tests to cover Markdown-aware height measurement, italic styling, and terminal hyperlinks in selected choices. Update the long-choice snapshot to show text starting beside its numbered prefix. GitOrigin-RevId: 7a1292ac7dff16b55439888d569fc7fa7c6ef689 --- .../src/bottom_pane/async_questions/input.rs | 2 +- .../src/bottom_pane/async_questions/layout.rs | 8 +++- .../src/bottom_pane/async_questions/mod.rs | 46 +++++++------------ .../src/bottom_pane/async_questions/render.rs | 8 +--- .../async_questions/state_tests.rs | 7 ++- codex-rs/tui/src/bottom_pane/command_popup.rs | 4 +- .../tui/src/bottom_pane/file_search_popup.rs | 7 +-- .../src/bottom_pane/list_selection_view.rs | 2 +- .../src/bottom_pane/selection_popup_common.rs | 2 + .../src/bottom_pane/selection_row_links.rs | 3 ++ codex-rs/tui/src/bottom_pane/skill_popup.rs | 5 +- ...ests__questions_tests__named_question.snap | 3 +- codex-rs/tui/src/markdown.rs | 28 ++++++++++- 13 files changed, 68 insertions(+), 57 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/async_questions/input.rs b/codex-rs/tui/src/bottom_pane/async_questions/input.rs index a240c2c94e10..cc4fb8884bd5 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/input.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/input.rs @@ -89,7 +89,7 @@ impl AsyncQuestions { let input = self.other_input_area(area); let named_area = Rect::new(area.x, area.y, area.width, input.y.saturating_sub(area.y)); - let mut rows = self.option_rows(); + let mut rows = self.option_rows(named_area.width); rows.pop(); let mut state = ScrollState::default(); while state.scroll_top + 1 < rows.len() diff --git a/codex-rs/tui/src/bottom_pane/async_questions/layout.rs b/codex-rs/tui/src/bottom_pane/async_questions/layout.rs index 55adcd55f5f8..43da721cb3ce 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/layout.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/layout.rs @@ -13,6 +13,9 @@ pub(super) struct LayoutSections { pub(super) question_area: Rect, pub(super) question_lines: Vec, pub(super) options_area: Rect, + /// Layout already measured the wrapped choices, so the footer can use this flag + /// without parsing and wrapping their Markdown again. + pub(super) show_option_tip: bool, pub(super) notes_area: Rect, pub(super) footer_lines: u16, pub(super) spacer_after_input: u16, @@ -21,11 +24,12 @@ pub(super) struct LayoutSections { impl AsyncQuestions { pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { let has_options = self.has_options(); + let full_options_height = self.options_required_height(area.width); let mut question_lines = self.wrapped_question_lines(area.width); let mut footer_pref = self.footer_lines(area.width, /*option_tip*/ None).len() as u16; if has_options && question_lines.len() - + usize::from(self.options_required_height(area.width)) + + usize::from(full_options_height) + usize::from(footer_pref) + usize::from(self.unanswered_count() > 1) + usize::from(DESIRED_SPACERS_BETWEEN_SECTIONS) @@ -50,7 +54,6 @@ impl AsyncQuestions { let footer_lines; let notes_height = if has_options { - let full_options_height = self.options_required_height(area.width); let min_options_height = remaining.min(1); // Reserve hints and spacers while retaining a row for the selected option. let reserved = footer_pref + progress_pref + DESIRED_SPACERS_BETWEEN_SECTIONS; @@ -107,6 +110,7 @@ impl AsyncQuestions { question_area, question_lines, options_area, + show_option_tip: options_height > 0 && options_height < full_options_height, notes_area, footer_lines, spacer_after_input, diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs index edf4e40d749d..12dcb63d7d6f 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -16,7 +16,9 @@ use crate::key_hint::KeyBindingListExt; use crate::keymap::KeymapContext; use crate::keymap::ListAction; use crate::keymap::RuntimeKeymap; +use crate::markdown::render_markdown_for_viewport; use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; use codex_protocol::items::AsyncUserInputQuestion; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -152,30 +154,8 @@ impl AsyncQuestions { } pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec { - let width = usize::from(width.max(1)); self.current_question() - .map(|question| { - crate::markdown_render::render_markdown_lines_with_width_cwd_and_hidden_link_destinations( - &question.title, - Some(width), - /*cwd*/ None, - &crate::markdown_render::hide_web_link_destination, - ) - .into_iter() - .flat_map(|line| { - if line.width() <= width { - vec![line] - } else { - // The question layout counts physical rows, so break overwide Markdown - // tokens while preserving their hyperlink destinations. - crate::terminal_hyperlinks::remap_source_wrapped_line( - &line, - crate::wrapping::word_wrap_line_with_source(&line.line, width), - ) - } - }) - .collect() - }) + .map(|question| render_markdown_for_viewport(&question.title, width)) .unwrap_or_default() } @@ -183,7 +163,7 @@ impl AsyncQuestions { !self.has_options() || self.other_selected() } - pub(super) fn option_rows(&self) -> Vec { + pub(super) fn option_rows(&self, width: u16) -> Vec { if !self.has_options() { return Vec::new(); }; @@ -200,14 +180,20 @@ impl AsyncQuestions { }; let number = index + 1; let prefix = format!("{prefix} {number}. "); - GenericDisplayRow { - // Other stays an inline editor with foreground-only focus. - selection_style: (index < self.options().len()) - .then(super::picker_style::selection_style), + let indent = prefix.width(); + let is_choice = index < self.options().len(); + let mut row = GenericDisplayRow { name: format!("{prefix}{label}"), - wrap_indent: Some(prefix.width()), + wrap_indent: Some(indent), + selection_style: is_choice.then(super::picker_style::selection_style), ..Default::default() + }; + if is_choice && usize::from(width) > indent { + let lines = render_markdown_for_viewport(label, width - indent as u16); + row.wrapped_name = + prefix_hyperlink_lines(lines, prefix.into(), " ".repeat(indent).into()); } + row }) .collect() } @@ -216,7 +202,7 @@ impl AsyncQuestions { if !self.has_options() { return 0; } - let rows = self.option_rows(); + let rows = self.option_rows(width); if self.other_selected() { let prefix = self.other_prefix_width(width); measure_rows_height( diff --git a/codex-rs/tui/src/bottom_pane/async_questions/render.rs b/codex-rs/tui/src/bottom_pane/async_questions/render.rs index 037fc8d7235b..5cbfae52ac79 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/render.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/render.rs @@ -56,11 +56,10 @@ impl Renderable for AsyncQuestions { ) .render(sections.question_area, buf); - let option_rows = self.option_rows(); - if self.other_selected() { self.render_inline_options(sections.options_area, buf); } else if self.has_options() && sections.options_area.height > 0 { + let option_rows = self.option_rows(sections.options_area.width); let mut options_state = self.state.pending[self.state.current_idx].options_state; let selected = options_state.selected_idx.unwrap_or(0); let heights: Vec<_> = option_rows @@ -131,10 +130,7 @@ impl Renderable for AsyncQuestions { buf, ); } - let option_tip = (self.has_options() - && sections.options_area.height > 0 - && self.options_required_height(content_area.width) > sections.options_area.height) - .then(|| self.option_tip()); + let option_tip = sections.show_option_tip.then(|| self.option_tip()); let lines = self .footer_lines(footer_area.width, option_tip) .into_iter() diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs index cf2801c0522a..738b67bf8e7a 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs @@ -376,7 +376,7 @@ fn question_options_reserve_wrapped_height_at_the_width_boundary() { let mut editor = editor(); editor.state.pending.remove(/*index*/ 0); editor.state.pending[0].question.options = - Some(vec!["abcdefgh ijklmno".into(), "pqrstuvw xyzabcd".into()]); + Some(vec!["abcdefgh *ijklmno*".into(), "pqrstuvw xyzabcd".into()]); editor.restore_current_draft(); // The five-column option prefix makes each named choice exactly 21 columns. @@ -589,15 +589,20 @@ fn named_choices_share_selection_while_other_keeps_its_inline_cursor() { || { let mut editor = editor(); editor.navigate(/*forward*/ true); + editor.state.pending[1].question.options = + Some(vec!["*Named* [guide](https://example.com)".into()]); + let linked_g = crate::terminal_hyperlinks::osc8_hyperlink("https://example.com", "g"); for width in [40, 80] { editor.select_option(/*index*/ 0); let buffer = render_editor(&editor, width, /*height*/ 16); + assert!(buffer.content.iter().any(|cell| cell.symbol() == linked_g)); let cell = buffer .content .iter() .find(|cell| cell.symbol() == "N") .unwrap(); assert_eq!(cell.bg, crate::bottom_pane::selection_style().bg.unwrap()); + assert!(cell.modifier.contains(Modifier::ITALIC)); editor.select_option(/*index*/ 1); let buffer = render_editor(&editor, width, /*height*/ 16); let cursor = editor.cursor_pos(buffer.area).unwrap(); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index f75d17f57d54..93a89e810f69 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -234,7 +234,6 @@ impl CommandPopup { .to_string(); let unavailable = self.is_unavailable(&item); GenericDisplayRow { - category_tag: None, name, name_prefix_spans: vec![if self.state.selected_idx == Some(index) && !unavailable @@ -245,12 +244,11 @@ impl CommandPopup { }], selection_style: Some(selection_style()), match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - display_shortcut: None, description: Some(description), - wrap_indent: None, is_disabled: unavailable, disabled_reason: unavailable .then(|| "not available in a side conversation".to_string()), + ..Default::default() } }) .collect() diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index e311f4809c19..243a6a42694d 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -155,7 +155,6 @@ impl WidgetRef for &FileSearchPopup { fuzzy_match(&name, &self.display_query).map(|(indices, _)| indices) }; GenericDisplayRow { - category_tag: None, selection_style: Some(super::picker_style::selection_style()), name, name_prefix_spans: vec![ @@ -167,11 +166,7 @@ impl WidgetRef for &FileSearchPopup { .into(), ], match_indices, - display_shortcut: None, - description: None, - wrap_indent: None, - is_disabled: false, - disabled_reason: None, + ..Default::default() } }) .collect() diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7a0fdf10d101..439717537cf6 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -812,11 +812,11 @@ impl ListSelectionView { .auto_number_shortcuts .then_some(item.display_shortcut) .flatten(), - match_indices: None, description, wrap_indent, is_disabled, disabled_reason: item.disabled_reason.clone(), + ..Default::default() } }) }) diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 0059d5612490..b223430cd47a 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -36,6 +36,8 @@ use hyperlinks::wrap_two_column_row; #[derive(Default)] pub(crate) struct GenericDisplayRow { pub name: String, + /// Rich label already laid out at the picker width, including its gutter and links. + pub wrapped_name: Vec, /// Optional full-width selection treatment; other rows use the shared text accent. pub selection_style: Option