diff --git a/.bazelrc b/.bazelrc index a5fea40281..911e0802b5 100644 --- a/.bazelrc +++ b/.bazelrc @@ -183,5 +183,15 @@ common:ci-v8 --build_metadata=TAG_os=linux common:ci-v8 --config=remote common:ci-v8 --strategy=remote +# 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 + # Optional per-user local overrides. try-import %workspace%/user.bazelrc diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 5caef01e85..a0297c269a 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -11,11 +11,11 @@ "path": "codex" }, "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl\\.zst$", + "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl\\.zst$", + "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "windows-x86_64": { @@ -84,6 +84,18 @@ } } }, + "bwrap": { + "platforms": { + "linux-x86_64": { + "regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$", + "path": "bwrap" + }, + "linux-aarch64": { + "regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$", + "path": "bwrap" + } + } + }, "codex-command-runner": { "platforms": { "windows-x86_64": { diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index ec73e0e5a7..5ad6d3c408 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -63,8 +63,10 @@ def bazel_output_files( platform: str, labels: list[str], compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" + bazel_configs = bazel_configs or [] result = subprocess.run( [ "bazel", @@ -72,6 +74,7 @@ def bazel_output_files( "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", + *[f"--config={config}" for config in bazel_configs], "--output=files", expression, ], @@ -87,7 +90,9 @@ def bazel_build( platform: str, labels: list[str], compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> None: + bazel_configs = bazel_configs or [] subprocess.run( [ "bazel", @@ -95,6 +100,7 @@ def bazel_build( "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", + *[f"--config={config}" for config in bazel_configs], *labels, ], cwd=ROOT, @@ -106,13 +112,14 @@ def ensure_bazel_output_files( platform: str, labels: list[str], compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> list[Path]: - outputs = bazel_output_files(platform, labels, compilation_mode) + outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) if all(path.exists() for path in outputs): return outputs - bazel_build(platform, labels, compilation_mode) - outputs = bazel_output_files(platform, labels, compilation_mode) + bazel_build(platform, labels, compilation_mode, bazel_configs) + outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) missing = [str(path) for path in outputs if not path.exists()] if missing: raise SystemExit(f"missing built outputs for {labels}: {missing}") @@ -187,8 +194,9 @@ def single_bazel_output_file( platform: str, label: str, compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> Path: - outputs = ensure_bazel_output_files(platform, [label], compilation_mode) + outputs = ensure_bazel_output_files(platform, [label], compilation_mode, bazel_configs) if len(outputs) != 1: raise SystemExit(f"expected exactly one output for {label}, found {outputs}") return outputs[0] @@ -198,11 +206,17 @@ def merged_musl_archive( platform: str, lib_path: Path, compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> Path: - llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode) - llvm_ranlib = single_bazel_output_file(platform, LLVM_RANLIB_LABEL, compilation_mode) + llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode, bazel_configs) + llvm_ranlib = single_bazel_output_file( + platform, + LLVM_RANLIB_LABEL, + compilation_mode, + bazel_configs, + ) runtime_archives = [ - single_bazel_output_file(platform, label, compilation_mode) + single_bazel_output_file(platform, label, compilation_mode, bazel_configs) for label in MUSL_RUNTIME_ARCHIVE_LABELS ] @@ -233,11 +247,13 @@ def stage_release_pair( target: str, output_dir: Path, compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> None: outputs = ensure_bazel_output_files( platform, [release_pair_label(target)], compilation_mode, + bazel_configs, ) try: @@ -254,7 +270,7 @@ def stage_release_pair( staged_library = output_dir / staged_archive_name(target, lib_path) staged_binding = output_dir / f"src_binding_release_{target}.rs" source_archive = ( - merged_musl_archive(platform, lib_path, compilation_mode) + merged_musl_archive(platform, lib_path, compilation_mode, bazel_configs) if is_musl_archive_target(target, lib_path) else lib_path ) @@ -293,6 +309,12 @@ def parse_args() -> argparse.Namespace: stage_release_pair_parser.add_argument("--platform", required=True) stage_release_pair_parser.add_argument("--target", required=True) stage_release_pair_parser.add_argument("--output-dir", required=True) + stage_release_pair_parser.add_argument( + "--bazel-config", + action="append", + default=[], + dest="bazel_configs", + ) stage_release_pair_parser.add_argument( "--compilation-mode", default="fastbuild", @@ -330,6 +352,7 @@ def main() -> int: target=args.target, output_dir=Path(args.output_dir), compilation_mode=args.compilation_mode, + bazel_configs=args.bazel_configs, ) return 0 if args.command == "resolved-v8-crate-version": diff --git a/.github/scripts/verify_cargo_workspace_manifests.py b/.github/scripts/verify_cargo_workspace_manifests.py index 4812e2428d..93b41ea59f 100644 --- a/.github/scripts/verify_cargo_workspace_manifests.py +++ b/.github/scripts/verify_cargo_workspace_manifests.py @@ -25,7 +25,10 @@ TOP_LEVEL_NAME_EXCEPTIONS = { UTILITY_NAME_EXCEPTIONS = { "path-utils": "codex-utils-path", } -MANIFEST_FEATURE_EXCEPTIONS = {} +MANIFEST_FEATURE_EXCEPTIONS = { + "codex-rs/code-mode/Cargo.toml": {"sandbox": ("v8/v8_enable_sandbox",)}, + "codex-rs/v8-poc/Cargo.toml": {"sandbox": ("v8/v8_enable_sandbox",)}, +} OPTIONAL_DEPENDENCY_EXCEPTIONS = set() INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS = {} diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index aed3f7a1b3..79b49ddb10 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -371,6 +371,22 @@ jobs: -- \ "${bazel_targets[@]}" + - name: Verify Bazel builds bwrap + if: runner.os == 'Linux' + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + ./.github/scripts/run-bazel-ci.sh \ + --remote-download-toplevel \ + --print-failed-action-summary \ + -- \ + build \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + --build_metadata=TAG_job=verify-bwrap \ + -- \ + //codex-rs/bwrap:bwrap + - name: Upload Bazel execution logs if: always() && !cancelled() continue-on-error: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb5ed5b5bf..699ab070c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,12 @@ jobs: CODEX_VERSION=0.125.0 WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298" OUTPUT_DIR="${RUNNER_TEMP}" + # This reused workflow predates the standalone bwrap artifact. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ + --allow-missing-native-component bwrap \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index faab87f97b..a7700ac924 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -96,7 +96,7 @@ jobs: target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl @@ -108,7 +108,7 @@ jobs: target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl @@ -255,6 +255,24 @@ jobs: with: target: ${{ matrix.target }} + - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} + name: Build bwrap and export digest + shell: bash + run: | + set -euo pipefail + target="${{ matrix.target }}" + cargo build --target "$target" --release --timings --bin bwrap + + bwrap_path="target/${target}/release/bwrap" + if [[ ! -f "$bwrap_path" ]]; then + echo "bwrap binary ${bwrap_path} not found" + exit 1 + fi + + digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" + echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" + echo "Built bwrap ${bwrap_path} with sha256:${digest}" + - name: Cargo build shell: bash run: | @@ -361,6 +379,17 @@ jobs: fi done + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then + bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" + rm -rf "$bundle_root" + mkdir -p "$bundle_root/codex-resources" + cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" + cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" + chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" + tar -C "$bundle_root" -cf - codex codex-resources/bwrap | + zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + fi + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi @@ -384,7 +413,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -404,8 +433,8 @@ jobs: - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files as well as the new .tar.gz - # equivalents we generated in the previous step. + # Upload the per-binary .zst files, .tar.gz equivalents, and any + # prebuilt archives staged above. path: | codex-rs/dist/${{ matrix.target }}/* diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index ee92eff4fa..bf9dcbfd6f 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -1,20 +1,12 @@ name: rusty-v8-release on: - workflow_dispatch: - inputs: - release_tag: - description: Optional release tag. Defaults to rusty-v8-v. - required: false - type: string - publish: - description: Publish the staged musl artifacts to a GitHub release. - required: false - default: true - type: boolean + push: + tags: + - "rusty-v8-v*.*.*" concurrency: - group: ${{ github.workflow }}::${{ inputs.release_tag || github.run_id }} + group: ${{ github.workflow }}::${{ github.ref_name }} cancel-in-progress: false jobs: @@ -43,15 +35,17 @@ jobs: - name: Resolve release tag id: release_tag env: - RELEASE_TAG_INPUT: ${{ inputs.release_tag }} + GITHUB_REF_NAME: ${{ github.ref_name }} V8_VERSION: ${{ steps.v8_version.outputs.version }} shell: bash run: | set -euo pipefail - release_tag="${RELEASE_TAG_INPUT}" - if [[ -z "${release_tag}" ]]; then - release_tag="rusty-v8-v${V8_VERSION}" + expected_release_tag="rusty-v8-v${V8_VERSION}" + release_tag="${GITHUB_REF_NAME}" + if [[ "${release_tag}" != "${expected_release_tag}" ]]; then + echo "Tag ${release_tag} does not match resolved v8 crate version ${V8_VERSION}." >&2 + exit 1 fi echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" @@ -111,6 +105,7 @@ jobs: -c opt "--platforms=@llvm//platforms:${PLATFORM}" + --config=v8-release-compat "${pair_target}" "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) @@ -134,6 +129,7 @@ jobs: --platform "${PLATFORM}" \ --target "${TARGET}" \ --compilation-mode opt \ + --bazel-config v8-release-compat \ --output-dir "dist/${TARGET}" - name: Upload staged musl artifacts @@ -143,7 +139,6 @@ jobs: path: dist/${{ matrix.target }}/* publish-release: - if: ${{ inputs.publish }} needs: - metadata - build @@ -153,16 +148,6 @@ jobs: actions: read steps: - - name: Ensure publishing from default branch - if: ${{ github.ref_name != github.event.repository.default_branch }} - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - shell: bash - run: | - set -euo pipefail - echo "Publishing is only allowed from ${DEFAULT_BRANCH}; current ref is ${GITHUB_REF_NAME}." >&2 - exit 1 - - name: Ensure release tag is new env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 119d042275..3b59348775 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -105,6 +105,7 @@ jobs: bazel_args=( build "--platforms=@llvm//platforms:${PLATFORM}" + --config=v8-release-compat "${pair_target}" "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) @@ -127,6 +128,7 @@ jobs: python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ --platform "${PLATFORM}" \ --target "${TARGET}" \ + --bazel-config v8-release-compat \ --output-dir "dist/${TARGET}" - name: Upload staged musl artifacts diff --git a/MODULE.bazel b/MODULE.bazel index be00937946..f675038791 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -327,6 +327,18 @@ crate.annotation( "RUSTY_V8_SRC_BINDING_PATH": "$(execpath @v8_targets//:rusty_v8_binding_for_target)", }, crate = "v8", + # Keep the Rust feature aligned with the source-built Bazel artifacts. + # Windows MSVC still consumes upstream non-sandboxed prebuilts. + crate_features_select = { + "aarch64-apple-darwin": ["v8_enable_sandbox"], + "aarch64-pc-windows-gnullvm": ["v8_enable_sandbox"], + "aarch64-unknown-linux-gnu": ["v8_enable_sandbox"], + "aarch64-unknown-linux-musl": ["v8_enable_sandbox"], + "x86_64-apple-darwin": ["v8_enable_sandbox"], + "x86_64-pc-windows-gnullvm": ["v8_enable_sandbox"], + "x86_64-unknown-linux-gnu": ["v8_enable_sandbox"], + "x86_64-unknown-linux-musl": ["v8_enable_sandbox"], + }, gen_build_script = "on", patch_args = ["-p1"], patches = [ diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index eda6c26152..c38fed68d9 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -69,8 +69,8 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = { PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { "codex": [], - "codex-linux-x64": ["codex", "rg"], - "codex-linux-arm64": ["codex", "rg"], + "codex-linux-x64": ["bwrap", "codex", "rg"], + "codex-linux-arm64": ["bwrap", "codex", "rg"], "codex-darwin-x64": ["codex", "rg"], "codex-darwin-arm64": ["codex", "rg"], "codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], @@ -87,6 +87,7 @@ PACKAGE_TARGET_FILTERS: dict[str, str] = { PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS) COMPONENT_DEST_DIR: dict[str, str] = { + "bwrap": "codex-resources", "codex": "codex", "codex-responses-api-proxy": "codex-responses-api-proxy", "codex-windows-sandbox-setup": "codex", @@ -137,6 +138,16 @@ def parse_args() -> argparse.Namespace: type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) + parser.add_argument( + "--allow-missing-native-component", + dest="allow_missing_native_components", + action="append", + default=[], + help=( + "Native component that may be absent from --vendor-src. Intended for CI " + "compatibility with older artifact workflows; releases should not use this." + ), + ) return parser.parse_args() @@ -177,6 +188,7 @@ def main() -> int: staging_dir, native_components, target_filter={target_filter} if target_filter else None, + allow_missing_components=set(args.allow_missing_native_components), ) if release_version: @@ -365,12 +377,14 @@ def copy_native_binaries( staging_dir: Path, components: list[str], target_filter: set[str] | None = None, + allow_missing_components: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") components_set = {component for component in components if component in COMPONENT_DEST_DIR} + allow_missing_components = allow_missing_components or set() if not components_set: return @@ -399,6 +413,8 @@ def copy_native_binaries( src_component_dir = target_dir / dest_dir_name if not src_component_dir.exists(): + if component in allow_missing_components: + continue raise RuntimeError( f"Missing native component '{component}' in vendor source: {src_component_dir}" ) diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 58fbd370fc..79f596fbaf 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install Codex native binaries (Rust CLI plus ripgrep helpers).""" +"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers).""" import argparse from contextlib import contextmanager @@ -42,8 +42,15 @@ class BinaryComponent: WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target) +LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target) BINARY_COMPONENTS = { + "bwrap": BinaryComponent( + artifact_prefix="bwrap", + dest_dir="codex-resources", + binary_basename="bwrap", + targets=LINUX_TARGETS, + ), "codex": BinaryComponent( artifact_prefix="codex", dest_dir="codex", @@ -135,7 +142,7 @@ def parse_args() -> argparse.Namespace: choices=tuple(list(BINARY_COMPONENTS) + ["rg"]), help=( "Limit installation to the specified components." - " May be repeated. Defaults to codex, codex-windows-sandbox-setup," + " May be repeated. Defaults to bwrap, codex, codex-windows-sandbox-setup," " codex-command-runner, and rg." ), ) @@ -159,6 +166,7 @@ def main() -> int: vendor_dir.mkdir(parents=True, exist_ok=True) components = args.components or [ + "bwrap", "codex", "codex-windows-sandbox-setup", "codex-command-runner", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 53a1c4cc6b..f78f27ffa3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1944,6 +1944,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "tempfile", "tokio", "tokio-tungstenite", "toml 0.9.11+spec-1.1.0", @@ -2125,6 +2126,26 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-builtin-mcps" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-config", + "codex-memories-mcp", + "codex-utils-absolute-path", + "pretty_assertions", +] + +[[package]] +name = "codex-bwrap" +version = "0.0.0" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -2161,6 +2182,7 @@ dependencies = [ "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", + "codex-builtin-mcps", "codex-chatgpt", "codex-cloud-tasks", "codex-config", @@ -2411,6 +2433,7 @@ dependencies = [ "bm25", "chrono", "clap", + "codex-agent-graph-store", "codex-analytics", "codex-api", "codex-app-server-protocol", @@ -2700,6 +2723,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "tempfile", "test-case", "thiserror 2.0.18", @@ -2708,6 +2732,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "wiremock", ] [[package]] @@ -2868,6 +2893,7 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-output-truncation", "futures", "pretty_assertions", "regex", @@ -2876,6 +2902,8 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tracing", + "uuid", ] [[package]] @@ -2899,7 +2927,6 @@ dependencies = [ name = "codex-linux-sandbox" version = "0.0.0" dependencies = [ - "cc", "clap", "codex-core", "codex-process-hardening", @@ -2909,11 +2936,11 @@ dependencies = [ "globset", "landlock", "libc", - "pkg-config", "pretty_assertions", "seccompiler", "serde", "serde_json", + "sha2", "tempfile", "tokio", "url", @@ -2983,6 +3010,7 @@ dependencies = [ "async-channel", "codex-api", "codex-async-utils", + "codex-builtin-mcps", "codex-config", "codex-exec-server", "codex-login", @@ -3104,6 +3132,19 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-message-history" +version = "0.0.0" +dependencies = [ + "codex-config", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-model-provider" version = "0.0.0" @@ -3646,6 +3687,7 @@ dependencies = [ "codex-install-context", "codex-login", "codex-mcp", + "codex-message-history", "codex-model-provider", "codex-model-provider-info", "codex-models-manager", @@ -4216,6 +4258,7 @@ dependencies = [ "codex-core", "codex-exec-server", "codex-features", + "codex-hooks", "codex-login", "codex-model-provider-info", "codex-models-manager", @@ -4232,6 +4275,7 @@ dependencies = [ "reqwest", "serde_json", "shlex", + "similar", "tempfile", "tokio", "tokio-tungstenite", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ce3e91626d..f1673d3bab 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -5,6 +5,8 @@ members = [ "agent-graph-store", "agent-identity", "backend-client", + "builtin-mcps", + "bwrap", "ansi-escape", "async-utils", "app-server", @@ -137,6 +139,7 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } +codex-builtin-mcps = { path = "builtin-mcps" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } @@ -169,6 +172,7 @@ codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-message-history = { path = "message-history" } codex-memories-mcp = { path = "memories/mcp" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } diff --git a/codex-rs/README.md b/codex-rs/README.md index 2cc3a6b8f1..d219061a35 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t ### Notifications -The legacy `notify` setting is deprecated and will be removed in a future release. Existing configurations still work, but new automation should use lifecycle hooks instead. The [notify documentation](../docs/config.md#notify) explains the remaining compatibility behavior. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. +You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. ### `codex exec` to run Codex programmatically/non-interactively diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 52ece67a13..fd294385e5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3,11 +3,14 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventRequest; use crate::events::GuardianApprovalRequestSource; use crate::events::GuardianReviewDecision; @@ -17,6 +20,8 @@ use crate::events::GuardianReviewTerminalStatus; use crate::events::GuardianReviewedAction; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemFinalApprovalOutcome; +use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_hook_run_metadata; @@ -61,8 +66,13 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NonSteerableTurnKind; use codex_app_server_protocol::RequestId; @@ -72,7 +82,9 @@ use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource as AppServerThreadSource; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; use codex_app_server_protocol::Turn; @@ -101,6 +113,7 @@ use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -112,17 +125,15 @@ use std::sync::Arc; use std::sync::Mutex; use tokio::sync::mpsc; -fn sample_thread(thread_id: &str, ephemeral: bool) -> Thread { - sample_thread_with_source(thread_id, ephemeral, AppServerSessionSource::Exec) -} - -fn sample_thread_with_source( +fn sample_thread_with_metadata( thread_id: &str, ephemeral: bool, source: AppServerSessionSource, + thread_source: Option, ) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral, @@ -134,6 +145,7 @@ fn sample_thread_with_source( cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source, + thread_source, agent_nickname: None, agent_role: None, git_info: None, @@ -148,7 +160,12 @@ fn sample_thread_start_response( model: &str, ) -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { - thread: sample_thread(thread_id, ephemeral), + thread: sample_thread_with_metadata( + thread_id, + ephemeral, + AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), + ), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, @@ -192,6 +209,7 @@ fn sample_thread_resume_response( ephemeral, model, AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), ) } @@ -200,9 +218,10 @@ fn sample_thread_resume_response_with_source( ephemeral: bool, model: &str, source: AppServerSessionSource, + thread_source: Option, ) -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { - thread: sample_thread_with_source(thread_id, ephemeral, source), + thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, @@ -240,6 +259,7 @@ fn sample_turn_start_response(turn_id: &str) -> ClientResponsePayload { ClientResponsePayload::TurnStart(codex_app_server_protocol::TurnStartResponse { turn: Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status: AppServerTurnStatus::InProgress, error: None, @@ -255,6 +275,7 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status: AppServerTurnStatus::InProgress, error: None, @@ -289,6 +310,7 @@ fn sample_turn_completed_notification( thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status, error: codex_error_info.map(|codex_error_info| AppServerTurnError { @@ -581,6 +603,90 @@ async fn ingest_turn_prerequisites( } } +async fn ingest_tool_review_prerequisites( + reducer: &mut AnalyticsReducer, + events: &mut Vec, +) { + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), events) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-1", /*ephemeral*/ false, "gpt-5", + )), + }, + events, + ) + .await; + events.clear(); +} + +fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { + AnalyticsFact::Initialize { + connection_id, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "linux".to_string(), + runtime_os_version: "24.04".to_string(), + runtime_arch: "x86_64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Websocket, + } +} + +fn sample_command_execution_item( + status: CommandExecutionStatus, + exit_code: Option, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: "item-1".to_string(), + command: "echo hi".to_string(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-1".to_string()), + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + +fn sample_command_execution_item_with_actions( + status: CommandExecutionStatus, + exit_code: Option, + duration_ms: Option, + command_actions: Vec, +) -> ThreadItem { + let mut item = sample_command_execution_item(status, exit_code, duration_ms); + let ThreadItem::CommandExecution { + command_actions: item_command_actions, + .. + } = &mut item + else { + unreachable!("sample command execution item should be CommandExecution"); + }; + *item_command_actions = command_actions; + item +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -744,7 +850,7 @@ fn compaction_event_serializes_expected_shape() { }, sample_app_server_client_metadata(), sample_runtime_metadata(), - Some("user"), + Some(ThreadSource::User), /*subagent_source*/ None, /*parent_thread_id*/ None, ), @@ -843,7 +949,7 @@ fn thread_initialized_event_serializes_expected_shape() { }, model: "gpt-5".to_string(), ephemeral: true, - thread_source: Some("user"), + thread_source: Some(ThreadSource::User), initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, @@ -884,6 +990,105 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn command_execution_event_serializes_expected_shape() { + let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base: CodexToolItemEventBase { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + app_server_client: CodexAppServerClientMetadata { + product_client_id: "codex_tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.2.3".to_string()), + rpc_transport: AppServerRpcTransport::Websocket, + experimental_api_enabled: Some(true), + }, + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + thread_source: Some("user"), + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at_ms: 123_000, + completed_at_ms: 125_000, + duration_ms: Some(2000), + execution_duration_ms: Some(1900), + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + requested_additional_permissions: false, + requested_network_access: false, + }, + command_execution_source: CommandExecutionSource::Agent, + exit_code: Some(0), + command_total_action_count: 4, + command_read_action_count: 1, + command_list_files_action_count: 1, + command_search_action_count: 1, + command_unknown_action_count: 1, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_command_execution_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "item_id": "item-1", + "app_server_client": { + "product_client_id": "codex_tui", + "client_name": "codex-tui", + "client_version": "1.2.3", + "rpc_transport": "websocket", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.99.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "thread_source": "user", + "subagent_source": null, + "parent_thread_id": null, + "tool_name": "shell", + "started_at_ms": 123000, + "completed_at_ms": 125000, + "duration_ms": 2000, + "execution_duration_ms": 1900, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_approval_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "requested_additional_permissions": false, + "requested_network_access": false, + "command_execution_source": "agent", + "exit_code": 0, + "command_total_action_count": 4, + "command_read_action_count": 1, + "command_list_files_action_count": 1, + "command_search_action_count": 1, + "command_unknown_action_count": 1 + } + }) + ); +} + #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); @@ -1090,6 +1295,7 @@ async fn compaction_event_ingests_custom_fact() { agent_nickname: None, agent_role: None, }), + Some(AppServerThreadSource::Subagent), )), }, &mut events, @@ -1289,6 +1495,114 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + 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: sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + 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( + CommandExecutionStatus::Completed, + Some(0), + Some(42), + vec![ + CommandAction::Read { + command: "cat README.md".to_string(), + name: "README.md".to_string(), + path: test_path_buf("/tmp/README.md").abs(), + }, + CommandAction::ListFiles { + command: "ls".to_string(), + path: None, + }, + CommandAction::Search { + command: "rg TODO".to_string(), + query: Some("TODO".to_string()), + path: None, + }, + CommandAction::Unknown { + command: "cargo test".to_string(), + }, + ], + ), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); + assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!( + payload[0]["event_params"]["command_execution_source"], + "agent" + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + assert_eq!( + payload[0]["event_params"]["final_approval_outcome"], + "unknown" + ); + assert_eq!( + payload[0]["event_params"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + assert_eq!(payload[0]["event_params"]["command_total_action_count"], 4); + assert_eq!(payload[0]["event_params"]["command_read_action_count"], 1); + assert_eq!( + payload[0]["event_params"]["command_list_files_action_count"], + 1 + ); + assert_eq!(payload[0]["event_params"]["command_search_action_count"], 1); + assert_eq!( + payload[0]["event_params"]["command_unknown_action_count"], + 1 + ); + assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); + assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_045); + assert_eq!(payload[0]["event_params"]["duration_ms"], 45); + assert_eq!(payload[0]["event_params"]["execution_duration_ms"], 42); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( @@ -1572,6 +1886,79 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { ); } +#[tokio::test] +async fn subagent_tool_items_inherit_parent_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-subagent".to_string(), + parent_thread_id: Some("thread-1".to_string()), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 128, + }, + )), + &mut events, + ) + .await; + events.clear(); + + 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( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + 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( + CommandExecutionStatus::Completed, + Some(0), + Some(42), + ), + }, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_source"], "subagent"); + assert_eq!(payload[0]["event_params"]["subagent_source"], "review"); + assert_eq!(payload[0]["event_params"]["parent_thread_id"], "thread-1"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); +} + #[test] fn plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { @@ -1816,6 +2203,7 @@ async fn reducer_ingests_skill_invoked_fact() { skill_name: "doc".to_string(), skill_scope: codex_protocol::protocol::SkillScope::User, skill_path, + plugin_id: None, invocation_type: InvocationType::Explicit, }], })), @@ -1833,8 +2221,10 @@ async fn reducer_ingests_skill_invoked_fact() { "event_params": { "product_client_id": originator().value, "skill_scope": "user", + "plugin_id": null, "repo_url": null, "thread_id": "thread-1", + "turn_id": "turn-1", "invoke_type": "explicit", "model_slug": "gpt-5" } @@ -1842,6 +2232,41 @@ async fn reducer_ingests_skill_invoked_fact() { ); } +#[tokio::test] +async fn reducer_includes_plugin_id_for_plugin_skill_invocations() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let skill_path = + PathBuf::from("/Users/abc/.codex/plugins/cache/test/sample/skills/doc/SKILL.md"); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(SkillInvokedInput { + tracking, + invocations: vec![SkillInvocation { + skill_name: "sample:doc".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path, + plugin_id: Some("sample@test".to_string()), + invocation_type: InvocationType::Explicit, + }], + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload[0]["event_params"]["plugin_id"], + json!("sample@test") + ); +} + #[tokio::test] async fn reducer_ingests_hook_run_fact() { let mut reducer = AnalyticsReducer::default(); @@ -1972,7 +2397,7 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, - thread_source: Some("user".to_string()), + thread_source: Some(ThreadSource::User), initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d54c53ede9..6d6d446560 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -333,10 +333,6 @@ impl AnalyticsEventsClient { }); } - pub fn track_notification(&self, notification: ServerNotification) { - self.record_fact(AnalyticsFact::Notification(Box::new(notification))); - } - pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { self.record_fact(AnalyticsFact::ServerRequest { connection_id, @@ -349,6 +345,21 @@ impl AnalyticsEventsClient { response: Box::new(response), }); } + + pub fn track_notification(&self, notification: ServerNotification) { + if !matches!( + notification, + ServerNotification::TurnStarted(_) + | ServerNotification::TurnCompleted(_) + | ServerNotification::ItemStarted(_) + | ServerNotification::ItemCompleted(_) + | ServerNotification::ItemGuardianApprovalReviewStarted(_) + | ServerNotification::ItemGuardianApprovalReviewCompleted(_) + ) { + return; + } + self.record_fact(AnalyticsFact::Notification(Box::new(notification))); + } } async fn send_track_events( diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 4b6fb54e95..3021d558d6 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -76,6 +76,7 @@ fn sample_thread_archive_request() -> ClientRequest { fn sample_thread(thread_id: &str) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: false, @@ -87,6 +88,7 @@ fn sample_thread(thread_id: &str) -> Thread { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: AppServerSessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -154,6 +156,7 @@ fn sample_turn_start_response() -> ClientResponsePayload { ClientResponsePayload::TurnStart(TurnStartResponse { turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8bd9440299..2232c88d39 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -20,6 +20,7 @@ use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; use crate::now_unix_seconds; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandExecutionSource; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -33,6 +34,7 @@ use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use serde::Serialize; @@ -61,6 +63,13 @@ pub(crate) enum TrackEventRequest { Compaction(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), + CommandExecution(CodexCommandExecutionEventRequest), + FileChange(CodexFileChangeEventRequest), + McpToolCall(CodexMcpToolCallEventRequest), + DynamicToolCall(CodexDynamicToolCallEventRequest), + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + WebSearch(CodexWebSearchEventRequest), + ImageGeneration(CodexImageGenerationEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -80,8 +89,10 @@ pub(crate) struct SkillInvocationEventRequest { pub(crate) struct SkillInvocationEventParams { pub(crate) product_client_id: Option, pub(crate) skill_scope: Option, + pub(crate) plugin_id: Option, pub(crate) repo_url: Option, pub(crate) thread_id: Option, + pub(crate) turn_id: Option, pub(crate) invoke_type: Option, pub(crate) model_slug: Option, } @@ -110,7 +121,7 @@ pub(crate) struct ThreadInitializedEventParams { pub(crate) runtime: CodexRuntimeMetadata, pub(crate) model: String, pub(crate) ephemeral: bool, - pub(crate) thread_source: Option<&'static str>, + pub(crate) thread_source: Option, pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, @@ -384,6 +395,199 @@ pub(crate) struct GuardianReviewEventPayload { pub(crate) guardian_review: GuardianReviewEventParams, } +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemFinalApprovalOutcome { + Unknown, + NotNeeded, + ConfigAllowed, + PolicyForbidden, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolItemEventBase { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + /// App-server ThreadItem.id. For tool-originated items this generally + /// corresponds to the originating core call_id. + pub(crate) item_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option<&'static str>, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_name: String, + pub(crate) started_at_ms: u64, + pub(crate) completed_at_ms: u64, + // Observed item lifecycle duration. This may undercount end-to-end execution + // for tools where app-server only sees part of the upstream flow. + pub(crate) duration_ms: Option, + pub(crate) execution_duration_ms: Option, + pub(crate) review_count: u64, + pub(crate) guardian_review_count: u64, + pub(crate) user_review_count: u64, + pub(crate) final_approval_outcome: ToolItemFinalApprovalOutcome, + pub(crate) terminal_status: ToolItemTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WebSearchActionKind { + Search, + OpenPage, + FindInPage, + Other, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) command_execution_source: CommandExecutionSource, + pub(crate) exit_code: Option, + pub(crate) command_total_action_count: u64, + pub(crate) command_read_action_count: u64, + pub(crate) command_list_files_action_count: u64, + pub(crate) command_search_action_count: u64, + pub(crate) command_unknown_action_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCommandExecutionEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) file_change_count: u64, + pub(crate) file_add_count: u64, + pub(crate) file_update_count: u64, + pub(crate) file_delete_count: u64, + pub(crate) file_move_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexFileChangeEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) mcp_server_name: String, + pub(crate) mcp_tool_name: String, + pub(crate) mcp_error_present: bool, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexMcpToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) dynamic_tool_name: String, + pub(crate) success: Option, + pub(crate) output_content_item_count: Option, + pub(crate) output_text_item_count: Option, + pub(crate) output_image_item_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexDynamicToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) sender_thread_id: String, + pub(crate) receiver_thread_count: u64, + pub(crate) receiver_thread_ids: Option>, + pub(crate) requested_model: Option, + pub(crate) requested_reasoning_effort: Option, + pub(crate) agent_state_count: Option, + pub(crate) completed_agent_count: Option, + pub(crate) failed_agent_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCollabAgentToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) web_search_action: Option, + pub(crate) query_present: bool, + pub(crate) query_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexWebSearchEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) revised_prompt_present: bool, + pub(crate) saved_path_present: bool, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexImageGenerationEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, @@ -429,7 +633,7 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) turn_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, - pub(crate) thread_source: Option<&'static str>, + pub(crate) thread_source: Option, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, pub(crate) trigger: CompactionTrigger, @@ -462,7 +666,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, - pub(crate) thread_source: Option, + pub(crate) thread_source: Option, pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, @@ -515,7 +719,7 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, - pub(crate) thread_source: Option, + pub(crate) thread_source: Option, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, @@ -618,7 +822,7 @@ pub(crate) fn codex_compaction_event_params( input: CodexCompactionEvent, app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, - thread_source: Option<&'static str>, + thread_source: Option, subagent_source: Option, parent_thread_id: Option, ) -> CodexCompactionEventParams { @@ -722,7 +926,7 @@ pub(crate) fn subagent_thread_started_event_request( runtime: current_runtime_metadata(), model: input.model, ephemeral: input.ephemeral, - thread_source: Some("subagent"), + thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id: input diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 424dd523b2..861e6534a2 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -173,6 +173,7 @@ pub struct SkillInvocation { pub skill_name: String, pub skill_scope: SkillScope, pub skill_path: PathBuf, + pub plugin_id: Option, pub invocation_type: InvocationType, } diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ed0f1036ca..2fb23199cb 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -51,3 +51,27 @@ pub fn now_unix_seconds() -> u64 { .unwrap_or_default() .as_secs() } + +pub fn now_unix_millis() -> u64 { + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +pub(crate) fn serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +pub(crate) fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +pub(crate) fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index b1dc822d43..81530444de 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2,15 +2,30 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCollabAgentToolCallEventParams; +use crate::events::CodexCollabAgentToolCallEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; +use crate::events::CodexDynamicToolCallEventParams; +use crate::events::CodexDynamicToolCallEventRequest; +use crate::events::CodexFileChangeEventParams; +use crate::events::CodexFileChangeEventRequest; use crate::events::CodexHookRunEventRequest; +use crate::events::CodexImageGenerationEventParams; +use crate::events::CodexImageGenerationEventRequest; +use crate::events::CodexMcpToolCallEventParams; +use crate::events::CodexMcpToolCallEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventParams; use crate::events::CodexTurnSteerEventRequest; +use crate::events::CodexWebSearchEventParams; +use crate::events::CodexWebSearchEventRequest; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; @@ -18,7 +33,11 @@ use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemFailureKind; +use crate::events::ToolItemFinalApprovalOutcome; +use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; +use crate::events::WebSearchActionKind; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_hook_run_metadata; @@ -47,14 +66,30 @@ use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use crate::now_unix_seconds; +use crate::option_i64_to_u64; +use crate::serialize_enum_as_string; +use crate::usize_to_u64; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallStatus; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WebSearchAction; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -64,6 +99,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; @@ -75,6 +111,7 @@ pub(crate) struct AnalyticsReducer { turns: HashMap, connections: HashMap, threads: HashMap, + tool_items_started_at_ms: HashMap, } struct ConnectionState { @@ -118,6 +155,19 @@ impl<'a> AnalyticsDropSite<'a> { } } + fn tool_item( + notification: &'a codex_app_server_protocol::ItemCompletedNotification, + item_id: &'a str, + ) -> Self { + Self { + event_name: "tool item", + thread_id: ¬ification.thread_id, + turn_id: Some(¬ification.turn_id), + review_id: None, + item_id: Some(item_id), + } + } + fn turn_steer(thread_id: &'a str) -> Self { Self { event_name: "turn steer", @@ -147,7 +197,7 @@ enum MissingAnalyticsContext { #[derive(Clone)] struct ThreadMetadataState { - thread_source: Option<&'static str>, + thread_source: Option, initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, @@ -156,6 +206,7 @@ struct ThreadMetadataState { impl ThreadMetadataState { fn from_thread_metadata( session_source: &SessionSource, + thread_source: Option, initialization_mode: ThreadInitializationMode, ) -> Self { let (subagent_source, parent_thread_id) = match session_source { @@ -172,7 +223,7 @@ impl ThreadMetadataState { | SessionSource::Unknown => (None, None), }; Self { - thread_source: session_source.thread_source_name(), + thread_source, initialization_mode, subagent_source, parent_thread_id, @@ -216,6 +267,13 @@ struct TurnState { steer_count: usize, } +#[derive(Hash, Eq, PartialEq)] +struct ToolItemKey { + thread_id: String, + turn_id: String, + item_id: String, +} + impl AnalyticsReducer { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -348,7 +406,7 @@ impl AnalyticsReducer { thread_state .metadata .get_or_insert_with(|| ThreadMetadataState { - thread_source: Some("subagent"), + thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id, @@ -496,11 +554,13 @@ impl AnalyticsReducer { skill_name: invocation.skill_name.clone(), event_params: SkillInvocationEventParams { thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), invoke_type: Some(invocation.invocation_type), model_slug: Some(tracking.model_slug.clone()), product_client_id: Some(originator().value), repo_url, skill_scope: Some(skill_scope.to_string()), + plugin_id: invocation.plugin_id, }, }, )); @@ -686,6 +746,62 @@ impl AnalyticsReducer { out: &mut Vec, ) { match notification { + ServerNotification::ItemStarted(notification) => { + let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + return; + }; + let Some(started_at_ms) = option_i64_to_u64(Some(notification.started_at_ms)) + else { + return; + }; + self.tool_items_started_at_ms.insert( + ToolItemKey { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + item_id: item_id.to_string(), + }, + started_at_ms, + ); + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + return; + }; + let key = ToolItemKey { + thread_id: notification.thread_id.clone(), + turn_id: notification.turn_id.clone(), + item_id: item_id.to_string(), + }; + let Some(started_at_ms) = self.tool_items_started_at_ms.remove(&key) else { + tracing::warn!( + thread_id = %notification.thread_id, + turn_id = %notification.turn_id, + item_id, + "dropping tool item analytics event: missing item started notification" + ); + return; + }; + let Some(completed_at_ms) = option_i64_to_u64(Some(notification.completed_at_ms)) + else { + return; + }; + let Some((connection_state, thread_metadata)) = self + .thread_context_or_warn(AnalyticsDropSite::tool_item(¬ification, item_id)) + else { + return; + }; + if let Some(event) = tool_item_event( + ¬ification.thread_id, + ¬ification.turn_id, + ¬ification.item, + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + ) { + out.push(event); + } + } ServerNotification::TurnStarted(notification) => { let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { connection_id: None, @@ -747,13 +863,16 @@ impl AnalyticsReducer { initialization_mode: ThreadInitializationMode, out: &mut Vec, ) { - let thread_source: SessionSource = thread.source.into(); + let session_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = - ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode); + let thread_metadata = ThreadMetadataState::from_thread_metadata( + &session_source, + thread.thread_source.map(Into::into), + initialization_mode, + ); self.threads.insert( thread_id.clone(), ThreadAnalyticsState { @@ -772,7 +891,7 @@ impl AnalyticsReducer { ephemeral: thread.ephemeral, thread_source: thread_metadata.thread_source, initialization_mode, - subagent_source: thread_metadata.subagent_source, + subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id, created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, @@ -855,7 +974,7 @@ impl AnalyticsReducer { accepted_turn_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), - thread_source: thread_metadata.thread_source.map(str::to_string), + thread_source: thread_metadata.thread_source, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id.clone(), num_input_images: pending_request.num_input_images, @@ -976,6 +1095,552 @@ fn warn_missing_analytics_context( ); } +fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { + match item { + ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageGeneration { id, .. } => Some(id), + ThreadItem::UserMessage { .. } + | ThreadItem::HookPrompt { .. } + | ThreadItem::AgentMessage { .. } + | ThreadItem::Plan { .. } + | ThreadItem::Reasoning { .. } + | ThreadItem::ImageView { .. } + | ThreadItem::EnteredReviewMode { .. } + | ThreadItem::ExitedReviewMode { .. } + | ThreadItem::ContextCompaction { .. } => None, + } +} + +fn tool_item_event( + thread_id: &str, + turn_id: &str, + item: &ThreadItem, + started_at_ms: u64, + completed_at_ms: u64, + connection_state: &ConnectionState, + thread_metadata: &ThreadMetadataState, +) -> Option { + let context = ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + }; + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let action_counts = command_action_counts(command_actions); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + command_execution_tool_name(*source).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + context, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: *source, + exit_code: *exit_code, + command_total_action_count: action_counts.total, + command_read_action_count: action_counts.read, + command_list_files_action_count: action_counts.list_files, + command_search_action_count: action_counts.search, + command_unknown_action_count: action_counts.unknown, + }, + }, + )) + } + ThreadItem::FileChange { + id, + changes, + status, + } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + let counts = file_change_counts(changes); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "apply_patch".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::FileChange(CodexFileChangeEventRequest { + event_type: "codex_file_change_event", + event_params: CodexFileChangeEventParams { + base, + file_change_count: usize_to_u64(changes.len()), + file_add_count: counts.add, + file_update_count: counts.update, + file_delete_count: counts.delete, + file_move_count: counts.move_, + }, + })) + } + ThreadItem::McpToolCall { + id, + server, + tool, + status, + error, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + context, + ); + Some(TrackEventRequest::McpToolCall( + CodexMcpToolCallEventRequest { + event_type: "codex_mcp_tool_call_event", + event_params: CodexMcpToolCallEventParams { + base, + mcp_server_name: server.clone(), + mcp_tool_name: tool.clone(), + mcp_error_present: error.is_some(), + }, + }, + )) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + content_items, + success, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + let counts = content_items + .as_ref() + .map(|items| dynamic_content_counts(items)); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + context, + ); + Some(TrackEventRequest::DynamicToolCall( + CodexDynamicToolCallEventRequest { + event_type: "codex_dynamic_tool_call_event", + event_params: CodexDynamicToolCallEventParams { + base, + dynamic_tool_name: tool.clone(), + success: *success, + output_content_item_count: counts.map(|counts| counts.total), + output_text_item_count: counts.map(|counts| counts.text), + output_image_item_count: counts.map(|counts| counts.image), + }, + }, + )) + } + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + model, + reasoning_effort, + agents_states, + .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + collab_agent_tool_name(tool).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + sender_thread_id: sender_thread_id.clone(), + receiver_thread_count: usize_to_u64(receiver_thread_ids.len()), + receiver_thread_ids: Some(receiver_thread_ids.clone()), + requested_model: model.clone(), + requested_reasoning_effort: reasoning_effort + .as_ref() + .and_then(serialize_enum_as_string), + agent_state_count: Some(usize_to_u64(agents_states.len())), + completed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| state.status == CollabAgentStatus::Completed) + .count(), + )), + failed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| { + matches!( + state.status, + CollabAgentStatus::Errored + | CollabAgentStatus::Shutdown + | CollabAgentStatus::NotFound + ) + }) + .count(), + )), + }, + }, + )) + } + ThreadItem::WebSearch { id, query, action } => { + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "web_search".to_string(), + ToolItemOutcome { + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::WebSearch(CodexWebSearchEventRequest { + event_type: "codex_web_search_event", + event_params: CodexWebSearchEventParams { + base, + web_search_action: action.as_ref().map(web_search_action_kind), + query_present: !query.trim().is_empty(), + query_count: web_search_query_count(query, action.as_ref()), + }, + })) + } + ThreadItem::ImageGeneration { + id, + status, + revised_prompt, + saved_path, + .. + } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "image_generation".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + context, + ); + Some(TrackEventRequest::ImageGeneration( + CodexImageGenerationEventRequest { + event_type: "codex_image_generation_event", + event_params: CodexImageGenerationEventParams { + base, + revised_prompt_present: revised_prompt.is_some(), + saved_path_present: saved_path.is_some(), + }, + }, + )) + } + _ => None, + } +} + +struct ToolItemOutcome { + terminal_status: ToolItemTerminalStatus, + failure_kind: Option, + execution_duration_ms: Option, +} + +#[derive(Default)] +struct CommandActionCounts { + total: u64, + read: u64, + list_files: u64, + search: u64, + unknown: u64, +} + +fn command_action_counts(command_actions: &[CommandAction]) -> CommandActionCounts { + let mut counts = CommandActionCounts { + total: usize_to_u64(command_actions.len()), + ..Default::default() + }; + for action in command_actions { + match action { + CommandAction::Read { .. } => counts.read += 1, + CommandAction::ListFiles { .. } => counts.list_files += 1, + CommandAction::Search { .. } => counts.search += 1, + CommandAction::Unknown { .. } => counts.unknown += 1, + } + } + counts +} + +#[derive(Clone, Copy)] +struct ToolItemContext<'a> { + started_at_ms: u64, + completed_at_ms: u64, + connection_state: &'a ConnectionState, + thread_metadata: &'a ThreadMetadataState, +} + +fn tool_item_base( + thread_id: &str, + turn_id: &str, + item_id: String, + tool_name: String, + outcome: ToolItemOutcome, + context: ToolItemContext<'_>, +) -> CodexToolItemEventBase { + let thread_metadata = context.thread_metadata; + CodexToolItemEventBase { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id, + app_server_client: context.connection_state.app_server_client.clone(), + runtime: context.connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source.map(ThreadSource::as_str), + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + tool_name, + started_at_ms: context.started_at_ms, + completed_at_ms: context.completed_at_ms, + // duration_ms reflects item lifecycle observed by app-server. For web + // search and image generation in particular, that can be narrower than + // full upstream execution time. + duration_ms: observed_duration_ms(context.started_at_ms, context.completed_at_ms), + execution_duration_ms: outcome.execution_duration_ms, + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: ToolItemFinalApprovalOutcome::Unknown, + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: false, + requested_network_access: false, + } +} + +fn observed_duration_ms(started_at_ms: u64, completed_at_ms: u64) -> Option { + completed_at_ms.checked_sub(started_at_ms) +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolItemTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + ), + _ => (ToolItemTerminalStatus::Completed, None), + } +} + +fn collab_agent_tool_name(tool: &CollabAgentTool) -> &'static str { + match tool { + CollabAgentTool::SpawnAgent => "spawn_agent", + CollabAgentTool::SendInput => "send_input", + CollabAgentTool::ResumeAgent => "resume_agent", + CollabAgentTool::Wait => "wait_agent", + CollabAgentTool::CloseAgent => "close_agent", + } +} + +#[derive(Default)] +struct FileChangeCounts { + add: u64, + update: u64, + delete: u64, + move_: u64, +} + +fn file_change_counts(changes: &[codex_app_server_protocol::FileUpdateChange]) -> FileChangeCounts { + let mut counts = FileChangeCounts::default(); + for change in changes { + match &change.kind { + PatchChangeKind::Add => counts.add += 1, + PatchChangeKind::Delete => counts.delete += 1, + PatchChangeKind::Update { move_path: Some(_) } => counts.move_ += 1, + PatchChangeKind::Update { move_path: None } => counts.update += 1, + } + } + counts +} + +#[derive(Clone, Copy)] +struct DynamicContentCounts { + total: u64, + text: u64, + image: u64, +} + +fn dynamic_content_counts(items: &[DynamicToolCallOutputContentItem]) -> DynamicContentCounts { + let mut text = 0; + let mut image = 0; + for item in items { + match item { + DynamicToolCallOutputContentItem::InputText { .. } => text += 1, + DynamicToolCallOutputContentItem::InputImage { .. } => image += 1, + } + } + DynamicContentCounts { + total: usize_to_u64(items.len()), + text, + image, + } +} + +fn web_search_action_kind(action: &WebSearchAction) -> WebSearchActionKind { + match action { + WebSearchAction::Search { .. } => WebSearchActionKind::Search, + WebSearchAction::OpenPage { .. } => WebSearchActionKind::OpenPage, + WebSearchAction::FindInPage { .. } => WebSearchActionKind::FindInPage, + WebSearchAction::Other => WebSearchActionKind::Other, + } +} + +fn web_search_query_count(query: &str, action: Option<&WebSearchAction>) -> Option { + match action { + Some(WebSearchAction::Search { query, queries }) => queries + .as_ref() + .map(|queries| usize_to_u64(queries.len())) + .or_else(|| query.as_ref().map(|_| 1)), + Some(WebSearchAction::OpenPage { .. }) + | Some(WebSearchAction::FindInPage { .. }) + | Some(WebSearchAction::Other) => None, + None => (!query.trim().is_empty()).then_some(1), + } +} + fn codex_turn_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, @@ -1021,7 +1686,7 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_metadata.thread_source.map(str::to_string), + thread_source: thread_metadata.thread_source, initialization_mode: thread_metadata.initialization_mode, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id.clone(), diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index d9c1ade097..fee29db47e 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -33,4 +33,5 @@ url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 1eb16166ac..6e10cef255 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -29,7 +29,6 @@ pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; pub use codex_app_server::in_process::InProcessServerEvent; use codex_app_server::in_process::InProcessStartArgs; use codex_app_server::in_process::LogDbLayer; -pub use codex_app_server::in_process::StateDbHandle; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; @@ -47,6 +46,7 @@ use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; +pub use codex_core::StateDbHandle; use codex_core::config::Config; pub use codex_exec_server::EnvironmentManager; pub use codex_exec_server::EnvironmentManagerArgs; @@ -72,12 +72,9 @@ pub mod legacy_core { pub use codex_core::DEFAULT_AGENTS_MD_FILENAME; pub use codex_core::LOCAL_AGENTS_MD_FILENAME; pub use codex_core::McpManager; - pub use codex_core::append_message_history_entry; pub use codex_core::check_execpolicy_for_warnings; pub use codex_core::format_exec_policy_error_with_source; pub use codex_core::grant_read_root_non_elevated; - pub use codex_core::lookup_message_history_entry; - pub use codex_core::message_history_metadata; pub use codex_core::web_search_detail; pub mod config { @@ -954,9 +951,13 @@ mod tests { use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_core::config::ConfigBuilder; + use codex_core::init_state_db_from_config; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; + use std::ops::Deref; + use std::path::Path; + use tempfile::TempDir; use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; @@ -975,19 +976,59 @@ mod tests { } } + async fn build_test_config_for_codex_home(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), + } + } + + struct TestClient { + _codex_home: TempDir, + client: InProcessAppServerClient, + } + + impl Deref for TestClient { + type Target = InProcessAppServerClient; + + fn deref(&self) -> &Self::Target { + &self.client + } + } + + impl TestClient { + async fn shutdown(self) -> IoResult<()> { + self.client.shutdown().await + } + } + async fn start_test_client_with_capacity( session_source: SessionSource, channel_capacity: usize, - ) -> InProcessAppServerClient { - InProcessAppServerClient::start(InProcessClientStartArgs { + ) -> TestClient { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config_for_codex_home(codex_home.path()).await); + let state_db = init_state_db_from_config(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); + let client = InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), - config: Arc::new(build_test_config().await), + config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, - state_db: None, + state_db: Some(state_db), environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, @@ -999,10 +1040,15 @@ mod tests { channel_capacity, }) .await - .expect("in-process app-server client should start") + .expect("in-process app-server client should start"); + + TestClient { + _codex_home: codex_home, + client, + } } - async fn start_test_client(session_source: SessionSource) -> InProcessAppServerClient { + async fn start_test_client(session_source: SessionSource) -> TestClient { start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await } @@ -1154,6 +1200,7 @@ mod tests { thread_id: "thread".to_string(), turn: codex_app_server_protocol::Turn { id: "turn".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, @@ -1984,6 +2031,7 @@ mod tests { thread_id: "thread".to_string(), turn: codex_app_server_protocol::Turn { id: "turn".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4bab786c48..f9f14adcd4 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2197,11 +2197,37 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListParams": { "type": "object" }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -2210,6 +2236,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -2217,6 +2252,39 @@ ], "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "type": "object" + }, "PluginSkillReadParams": { "properties": { "remoteMarketplaceName": { @@ -3256,13 +3324,6 @@ ], "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -3540,24 +3601,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -3951,20 +4012,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -4032,6 +4082,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -4140,20 +4198,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -4165,6 +4212,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "type": "object" @@ -4309,22 +4367,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ @@ -5154,6 +5201,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -5921,6 +5992,29 @@ "title": "WindowsSandbox/setupStartRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/readiness" + ], + "title": "WindowsSandbox/readinessRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 71ca4f8c90..ba89389e22 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3072,6 +3072,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -3088,6 +3092,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -3109,6 +3124,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -4094,6 +4110,14 @@ ], "type": "object" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStartedNotification": { "properties": { "thread": { @@ -4312,12 +4336,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -4400,6 +4433,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnPlanStep": { "properties": { "status": { 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 9a9bf10795..02fdbf787c 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 @@ -810,6 +810,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -1577,6 +1601,29 @@ "title": "WindowsSandbox/setupStartRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/readiness" + ], + "title": "WindowsSandbox/readinessRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, { "properties": { "id": { @@ -7177,13 +7224,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -9810,6 +9853,9 @@ "null" ] }, + "currentHash": { + "type": "string" + }, "displayOrder": { "format": "int64", "type": "integer" @@ -9857,9 +9903,13 @@ "format": "uint64", "minimum": 0.0, "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/v2/HookTrustStatus" } }, "required": [ + "currentHash", "displayOrder", "enabled", "eventName", @@ -9868,7 +9918,8 @@ "key", "source", "sourcePath", - "timeoutSec" + "timeoutSec", + "trustStatus" ], "type": "object" }, @@ -10058,6 +10109,15 @@ "title": "HookStartedNotification", "type": "object" }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { @@ -11266,6 +11326,7 @@ "properties": { "additionalSpeedTiers": { "default": [], + "description": "Deprecated: use `serviceTiers` instead.", "items": { "type": "string" }, @@ -11312,6 +11373,13 @@ "model": { "type": "string" }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/v2/ModelServiceTier" + }, + "type": "array" + }, "supportedReasoningEfforts": { "items": { "$ref": "#/definitions/v2/ReasoningEffortOption" @@ -11476,6 +11544,25 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { @@ -12412,6 +12499,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -12458,9 +12553,46 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -12469,6 +12601,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -12494,6 +12635,57 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/v2/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12643,6 +12835,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, @@ -12886,13 +13085,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -14421,13 +14616,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -15142,6 +15330,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -15158,6 +15350,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -15179,6 +15382,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -15354,24 +15558,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -15430,13 +15634,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -16861,20 +17061,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -16937,13 +17126,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -17052,6 +17237,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -17161,20 +17354,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -17186,6 +17368,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", @@ -17241,13 +17434,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -17616,12 +17805,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/v2/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/v2/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -17745,6 +17943,31 @@ "title": "TurnInterruptResponse", "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnPlanStep": { "properties": { "status": { @@ -17880,22 +18103,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ @@ -18335,6 +18547,27 @@ }, "type": "object" }, + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + }, + "WindowsSandboxReadinessResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" + }, "WindowsSandboxSetupCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "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 72f10873b7..79a3cd5fdf 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 @@ -1569,6 +1569,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -2336,6 +2360,29 @@ "title": "WindowsSandbox/setupStartRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/readiness" + ], + "title": "WindowsSandbox/readinessRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, { "properties": { "id": { @@ -3633,13 +3680,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -6377,6 +6420,9 @@ "null" ] }, + "currentHash": { + "type": "string" + }, "displayOrder": { "format": "int64", "type": "integer" @@ -6424,9 +6470,13 @@ "format": "uint64", "minimum": 0.0, "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/HookTrustStatus" } }, "required": [ + "currentHash", "displayOrder", "enabled", "eventName", @@ -6435,7 +6485,8 @@ "key", "source", "sourcePath", - "timeoutSec" + "timeoutSec", + "trustStatus" ], "type": "object" }, @@ -6625,6 +6676,15 @@ "title": "HookStartedNotification", "type": "object" }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { @@ -7877,6 +7937,7 @@ "properties": { "additionalSpeedTiers": { "default": [], + "description": "Deprecated: use `serviceTiers` instead.", "items": { "type": "string" }, @@ -7923,6 +7984,13 @@ "model": { "type": "string" }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/ModelServiceTier" + }, + "type": "array" + }, "supportedReasoningEfforts": { "items": { "$ref": "#/definitions/ReasoningEffortOption" @@ -8087,6 +8155,25 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { @@ -9023,6 +9110,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -9069,9 +9164,46 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -9080,6 +9212,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -9105,6 +9246,57 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9254,6 +9446,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, @@ -9497,13 +9696,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -12307,13 +12502,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -13028,6 +13216,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -13044,6 +13236,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -13065,6 +13268,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -13240,24 +13444,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -13316,13 +13520,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -14747,20 +14947,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -14823,13 +15012,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -14938,6 +15123,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -15047,20 +15240,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -15072,6 +15254,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", @@ -15127,13 +15320,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -15502,12 +15691,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -15631,6 +15829,31 @@ "title": "TurnInterruptResponse", "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnPlanStep": { "properties": { "status": { @@ -15766,22 +15989,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ @@ -16221,6 +16433,27 @@ }, "type": "object" }, + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + }, + "WindowsSandboxReadinessResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" + }, "WindowsSandboxSetupCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c348e7d955..87a826e5af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -352,13 +352,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -658,13 +654,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -754,13 +746,6 @@ }, "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ToolsV2": { "properties": { "view_image": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index 5190b22711..ae9cd9e63c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -47,6 +47,9 @@ "null" ] }, + "currentHash": { + "type": "string" + }, "displayOrder": { "format": "int64", "type": "integer" @@ -94,9 +97,13 @@ "format": "uint64", "minimum": 0.0, "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/HookTrustStatus" } }, "required": [ + "currentHash", "displayOrder", "enabled", "eventName", @@ -105,7 +112,8 @@ "key", "source", "sourcePath", - "timeoutSec" + "timeoutSec", + "trustStatus" ], "type": "object" }, @@ -124,6 +132,15 @@ ], "type": "string" }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index dc60c5b770..c0221805eb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -24,6 +24,7 @@ "properties": { "additionalSpeedTiers": { "default": [], + "description": "Deprecated: use `serviceTiers` instead.", "items": { "type": "string" }, @@ -70,6 +71,13 @@ "model": { "type": "string" }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/ModelServiceTier" + }, + "type": "array" + }, "supportedReasoningEfforts": { "items": { "$ref": "#/definitions/ReasoningEffortOption" @@ -120,6 +128,25 @@ ], "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index dc383608f2..bf1ce45755 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -347,6 +347,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 2762807c7d..c1736cdcc1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -366,6 +366,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index adb5021be8..bae3eb034f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -307,6 +307,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json index ee1ae48730..c269223068 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -4,9 +4,50 @@ "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" } }, "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -15,6 +56,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json new file mode 100644 index 0000000000..080ec980a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + } + }, + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json new file mode 100644 index 0000000000..28fcb665ba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + } + }, + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" +} \ No newline at end of file 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 0a2d41291b..a413c43230 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 970e2fe9ca..29d67403cd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -131,10 +131,11 @@ ], "type": "string" }, - "ServiceTier": { + "ThreadSource": { "enum": [ - "fast", - "flex" + "user", + "subagent", + "memory_consolidation" ], "type": "string" } @@ -214,24 +215,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ 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 f1bc9fdd24..77e4b98de2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1419,6 +1416,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1440,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2117,6 +2126,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -2225,12 +2242,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -2278,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -2564,13 +2615,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { 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 73d106eae0..2db4d82a4a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", 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 d17fb1f1c2..6158512a12 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", 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 958fcada32..b45a397df4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 9fe5c7f47f..5f07fe0149 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1010,13 +1010,6 @@ "danger-full-access" ], "type": "string" - }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -1101,20 +1094,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { 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 38d3307ccf..3b423118c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1419,6 +1416,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1440,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2117,6 +2126,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -2225,12 +2242,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -2278,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -2564,13 +2615,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 95e3ba07d6..abc55e9b9d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index d5f0e9bfcc..9a60049a61 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -165,10 +165,11 @@ ], "type": "string" }, - "ServiceTier": { + "ThreadSource": { "enum": [ - "fast", - "flex" + "user", + "subagent", + "memory_consolidation" ], "type": "string" }, @@ -287,20 +288,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -312,6 +302,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", 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 43c500858f..5b45c4a5fa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1419,6 +1416,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1440,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2117,6 +2126,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -2225,12 +2242,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -2278,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -2564,13 +2615,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { 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 129863eb75..b6820815ff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", 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 5f87912a35..080562604f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", 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 cfee23e24d..7acee5e91e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", 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 867402032f..f30549b64d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -312,13 +312,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "Settings": { "description": "Settings for a collaboration mode.", "properties": { @@ -593,22 +586,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ 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 b283376241..a24eb429dc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", 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 ab3e110cc3..0909cea3dc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json new file mode 100644 index 0000000000..de5ee264cb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 989dbb6551..1c03d2eb37 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -51,6 +51,7 @@ import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; import type { PluginShareListParams } from "./v2/PluginShareListParams"; import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; +import type { PluginShareUpdateTargetsParams } from "./v2/PluginShareUpdateTargetsParams"; import type { PluginSkillReadParams } from "./v2/PluginSkillReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; @@ -81,4 +82,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index a082e045fa..97ea435601 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,7 +64,6 @@ export type { ResponseItem } from "./ResponseItem"; export type { ReviewDecision } from "./ReviewDecision"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; -export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 508fe84e92..cc7e340ea3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -4,7 +4,6 @@ import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -20,4 +19,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 8ccd2b1825..94e3c30c92 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -5,5 +5,6 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; +import type { HookTrustStatus } from "./HookTrustStatus"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts new file mode 100644 index 0000000000..692fdc4c11 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookTrustStatus = "managed" | "untrusted" | "trusted" | "modified"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index f4cf5a9462..2354ffbf9e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -4,7 +4,12 @@ import type { InputModality } from "../InputModality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; +import type { ModelServiceTier } from "./ModelServiceTier"; import type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; import type { ReasoningEffortOption } from "./ReasoningEffortOption"; -export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, additionalSpeedTiers: Array, isDefault: boolean, }; +export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, +/** + * Deprecated: use `serviceTiers` instead. + */ +additionalSpeedTiers: Array, serviceTiers: Array, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts new file mode 100644 index 0000000000..09693d0788 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelServiceTier = { id: string, name: string, description: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts new file mode 100644 index 0000000000..8c2242163b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts new file mode 100644 index 0000000000..9e0ecc48e7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts new file mode 100644 index 0000000000..e54c129cbf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginSharePrincipalType = "user" | "group" | "workspace"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts index d2011984e3..c8df0d6c1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts @@ -2,5 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginShareTarget } from "./PluginShareTarget"; -export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, }; +export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, discoverability?: PluginShareDiscoverability | null, shareTargets?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts new file mode 100644 index 0000000000..fd1969087f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts new file mode 100644 index 0000000000..53ef2b3025 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginShareTarget } from "./PluginShareTarget"; + +export type PluginShareUpdateTargetsParams = { remotePluginId: string, shareTargets: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts new file mode 100644 index 0000000000..3d6f6e9ee0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipal } from "./PluginSharePrincipal"; + +export type PluginShareUpdateTargetsResponse = { principals: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index fe9e63703d..8a760e2e5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -11,4 +11,4 @@ export type PluginSummary = { id: string, name: string, source: PluginSource, in /** * Availability state for installing and using the plugin. */ -availability: PluginAvailability, interface: PluginInterface | null, }; +availability: PluginAvailability, interface: PluginInterface | null, keywords: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 7afe3e0c54..d05038701c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -3,7 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -16,4 +15,4 @@ export type ProfileV2 = {model: string | null, model_provider: string | null, ap * are routed for review. If omitted, the enclosing config default is * used. */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 8c4c9394bf..d917094e36 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -4,10 +4,15 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { GitInfo } from "./GitInfo"; import type { SessionSource } from "./SessionSource"; +import type { ThreadSource } from "./ThreadSource"; import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; export type Thread = { id: string, +/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, /** * Source thread id when this thread was created by forking another thread. */ @@ -52,6 +57,10 @@ cliVersion: string, * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ source: SessionSource, +/** + * Optional analytics source classification for this thread. + */ +threadSource: ThreadSource | null, /** * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index ba7119e9ed..6076a4bb14 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -1,11 +1,11 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; /** * There are two ways to fork a thread: @@ -19,8 +19,11 @@ import type { SandboxMode } from "./SandboxMode"; export type ThreadForkParams = {threadId: string, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean}; +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** + * Optional client-supplied analytics source classification for this forked thread. + */ +threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index ddcef104e9..c44533ec1a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index ac8b1e293b..6d1dbdca4f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -22,7 +21,7 @@ import type { SandboxMode } from "./SandboxMode"; export type ThreadResumeParams = {threadId: string, /** * Configuration overrides for the resumed thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f7627c07ae..f91756c7c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts new file mode 100644 index 0000000000..8f55524801 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadSource = "user" | "subagent" | "memory_consolidation"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 374ac2e681..30509ef6cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -2,15 +2,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; import type { ThreadStartSource } from "./ThreadStartSource"; -export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null}; +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /** + * Optional client-supplied analytics source classification for this thread. + */ +threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index ce28a4a1d7..9573bd7dee 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 844c09c4fd..6505ec345f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -3,15 +3,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; import type { TurnError } from "./TurnError"; +import type { TurnItemsView } from "./TurnItemsView"; import type { TurnStatus } from "./TurnStatus"; export type Turn = { id: string, /** - * Only populated on a `thread/resume` or `thread/fork` response. - * For all other responses and notifications returning a Turn, - * the items field will be an empty list. + * Thread items currently included in this turn payload. */ -items: Array, status: TurnStatus, +items: Array, +/** + * Describes how much of `items` has been loaded for this turn. + */ +itemsView: TurnItemsView, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts similarity index 71% rename from codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts index ce11286dbd..9056923065 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ServiceTier = "fast" | "flex"; +export type TurnItemsView = "notLoaded" | "summary" | "full"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 4af17115c8..b04919d86b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -4,7 +4,6 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -30,7 +29,7 @@ sandboxPolicy?: SandboxPolicy | null, /** model?: string | null, /** * Override the service tier for this turn and subsequent turns. */ -serviceTier?: ServiceTier | null | null, /** +serviceTier?: string | null | null, /** * Override the reasoning effort for this turn and subsequent turns. */ effort?: ReasoningEffort | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts new file mode 100644 index 0000000000..41b1161acf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WindowsSandboxReadiness = "ready" | "notConfigured" | "updateRequired"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts new file mode 100644 index 0000000000..bc42a1d962 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness"; + +export type WindowsSandboxReadinessResponse = { status: WindowsSandboxReadiness, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 037534a22f..6901e2a040 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -168,6 +168,7 @@ export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; +export type { HookTrustStatus } from "./HookTrustStatus"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; @@ -245,6 +246,7 @@ export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabil export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; +export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { ModelVerification } from "./ModelVerification"; export type { ModelVerificationNotification } from "./ModelVerificationNotification"; @@ -283,11 +285,17 @@ export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; +export type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; export type { PluginShareListItem } from "./PluginShareListItem"; export type { PluginShareListParams } from "./PluginShareListParams"; export type { PluginShareListResponse } from "./PluginShareListResponse"; +export type { PluginSharePrincipal } from "./PluginSharePrincipal"; +export type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; export type { PluginShareSaveParams } from "./PluginShareSaveParams"; export type { PluginShareSaveResponse } from "./PluginShareSaveResponse"; +export type { PluginShareTarget } from "./PluginShareTarget"; +export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams"; +export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse"; export type { PluginSkillReadParams } from "./PluginSkillReadParams"; export type { PluginSkillReadResponse } from "./PluginSkillReadResponse"; export type { PluginSource } from "./PluginSource"; @@ -394,6 +402,7 @@ export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; +export type { ThreadSource } from "./ThreadSource"; export type { ThreadSourceKind } from "./ThreadSourceKind"; export type { ThreadStartParams } from "./ThreadStartParams"; export type { ThreadStartResponse } from "./ThreadStartResponse"; @@ -423,6 +432,7 @@ export type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; +export type { TurnItemsView } from "./TurnItemsView"; export type { TurnPlanStep } from "./TurnPlanStep"; export type { TurnPlanStepStatus } from "./TurnPlanStepStatus"; export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification"; @@ -435,6 +445,8 @@ export type { TurnSteerResponse } from "./TurnSteerResponse"; export type { UserInput } from "./UserInput"; export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; +export type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness"; +export type { WindowsSandboxReadinessResponse } from "./WindowsSandboxReadinessResponse"; export type { WindowsSandboxSetupCompletedNotification } from "./WindowsSandboxSetupCompletedNotification"; export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 477d0427b2..682556b976 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -628,6 +628,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginShareSaveResponse, }, + PluginShareUpdateTargets => "plugin/share/updateTargets" { + params: v2::PluginShareUpdateTargetsParams, + serialization: global("config"), + response: v2::PluginShareUpdateTargetsResponse, + }, PluginShareList => "plugin/share/list" { params: v2::PluginShareListParams, serialization: global("config"), @@ -843,6 +848,11 @@ client_request_definitions! { serialization: global("windows-sandbox-setup"), response: v2::WindowsSandboxSetupStartResponse, }, + WindowsSandboxReadiness => "windowsSandbox/readiness" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("config"), + response: v2::WindowsSandboxReadinessResponse, + }, LoginAccount => "account/login/start" { params: v2::LoginAccountParams, @@ -2168,6 +2178,7 @@ mod tests { response: v2::ThreadStartResponse { thread: v2::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: true, @@ -2179,6 +2190,7 @@ mod tests { cwd: cwd.clone(), cli_version: "0.0.0".to_string(), source: v2::SessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -2208,6 +2220,7 @@ mod tests { "response": { "thread": { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "forkedFromId": null, "preview": "first prompt", "ephemeral": true, @@ -2221,6 +2234,7 @@ mod tests { "cwd": absolute_path_string("tmp"), "cliVersion": "0.0.0", "source": "exec", + "threadSource": null, "agentNickname": null, "agentRole": null, "gitInfo": null, 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 6228d754d6..1f45180c9e 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::ThreadItem; use crate::protocol::v2::Turn; use crate::protocol::v2::TurnError as V2TurnError; use crate::protocol::v2::TurnError; +use crate::protocol::v2::TurnItemsView; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; use crate::protocol::v2::WebSearchAction; @@ -1166,6 +1167,7 @@ impl From for Turn { Self { id: value.id, items: value.items, + items_view: TurnItemsView::Full, error: value.error, status: value.status, started_at: value.started_at, @@ -1180,6 +1182,7 @@ impl From<&PendingTurn> for Turn { Self { id: value.id.clone(), items: value.items.clone(), + items_view: TurnItemsView::Full, error: value.error.clone(), status: value.status.clone(), started_at: value.started_at, @@ -1453,6 +1456,7 @@ mod tests { started_at: None, completed_at: None, duration_ms: None, + items_view: TurnItemsView::Full, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -2723,6 +2727,7 @@ mod tests { started_at: None, completed_at: None, duration_ms: None, + items_view: TurnItemsView::Full, items: Vec::new(), }] ); @@ -2982,6 +2987,7 @@ mod tests { started_at: None, completed_at: None, duration_ms: None, + items_view: TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs deleted file mode 100644 index b813440d46..0000000000 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ /dev/null @@ -1,11906 +0,0 @@ -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::num::NonZeroUsize; -use std::path::PathBuf; - -use crate::RequestId; -use crate::protocol::common::AuthMode; -use crate::protocol::item_builders::convert_patch_changes; -use codex_experimental_api_macros::ExperimentalApi; -use codex_protocol::account::PlanType; -use codex_protocol::account::ProviderAccount; -use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; -use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; -use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; -use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; -use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; -use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; -use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; -use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; -use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; -use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; -use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::SandboxMode as CoreSandboxMode; -use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::Verbosity; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WebSearchToolConfig; -use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; -use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; -use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; -use codex_protocol::items::TurnItem as CoreTurnItem; -use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; -use codex_protocol::mcp::Resource as McpResource; -pub use codex_protocol::mcp::ResourceContent as McpResourceContent; -use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; -use codex_protocol::mcp::Tool as McpTool; -use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; -use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; -use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; -use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; -use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; -use codex_protocol::models::MessagePhase; -use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::default_input_modalities; -use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; -use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; -use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; -use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; -use codex_protocol::protocol::AgentStatus as CoreAgentStatus; -use codex_protocol::protocol::AskForApproval as CoreAskForApproval; -use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; -use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; -use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; -use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; -use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; -use codex_protocol::protocol::HookEventName as CoreHookEventName; -use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode; -use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType; -use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry; -use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; -use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; -use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; -use codex_protocol::protocol::HookScope as CoreHookScope; -use codex_protocol::protocol::HookSource as CoreHookSource; -use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; -use codex_protocol::protocol::ModelVerification as CoreModelVerification; -use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; -use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; -use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; -use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; -use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationVersion; -use codex_protocol::protocol::RealtimeOutputModality; -use codex_protocol::protocol::RealtimeVoice; -use codex_protocol::protocol::RealtimeVoicesList; -use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; -use codex_protocol::protocol::SessionSource as CoreSessionSource; -use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; -use codex_protocol::protocol::SkillInterface as CoreSkillInterface; -use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; -use codex_protocol::protocol::SkillScope as CoreSkillScope; -use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; -use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; -use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; -use codex_protocol::protocol::TokenUsage as CoreTokenUsage; -use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; -use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; -use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; -use codex_protocol::user_input::ByteRange as CoreByteRange; -use codex_protocol::user_input::TextElement as CoreTextElement; -use codex_protocol::user_input::UserInput as CoreUserInput; -use codex_utils_absolute_path::AbsolutePathBuf; -use schemars::JsonSchema; -use schemars::r#gen::SchemaGenerator; -use schemars::schema::InstanceType; -use schemars::schema::Metadata; -use schemars::schema::Schema; -use schemars::schema::SchemaObject; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; -use serde_with::serde_as; -use thiserror::Error; -use ts_rs::TS; - -// Macro to declare a camelCased API v2 enum mirroring a core enum which -// tends to use either snake_case or kebab-case. -macro_rules! v2_enum_from_core { - ( - $(#[$enum_meta:meta])* - pub enum $Name:ident from $Src:path { - $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? - } - ) => { - #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] - $(#[$enum_meta])* - #[serde(rename_all = "camelCase")] - #[ts(export_to = "v2/")] - pub enum $Name { - $( $(#[$variant_meta])* $Variant ),+ - } - - impl $Name { - pub fn to_core(self) -> $Src { - match self { $( $Name::$Variant => <$Src>::$Variant ),+ } - } - } - - impl From<$Src> for $Name { - fn from(value: $Src) -> Self { - match value { $( <$Src>::$Variant => $Name::$Variant ),+ } - } - } - }; -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum NonSteerableTurnKind { - Review, - Compact, -} - -/// This translation layer make sure that we expose codex error code in camel case. -/// -/// When an upstream HTTP status is available (for example, from the Responses API or a provider), -/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CodexErrorInfo { - ContextWindowExceeded, - UsageLimitExceeded, - ServerOverloaded, - CyberPolicy, - HttpConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Failed to connect to the response SSE stream. - ResponseStreamConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - InternalServerError, - Unauthorized, - BadRequest, - ThreadRollbackFailed, - SandboxError, - /// The response SSE stream disconnected in the middle of a turn before completion. - ResponseStreamDisconnected { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Reached the retry limit for responses. - ResponseTooManyFailedAttempts { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn - /// cannot accept same-turn steering, for example `/review` or manual `/compact`. - ActiveTurnNotSteerable { - #[serde(rename = "turnKind")] - #[ts(rename = "turnKind")] - turn_kind: NonSteerableTurnKind, - }, - Other, -} - -impl From for CodexErrorInfo { - fn from(value: CoreCodexErrorInfo) -> Self { - match value { - CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, - CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, - CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, - CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, - CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { - CodexErrorInfo::HttpConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { - CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, - CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, - CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, - CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, - CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, - CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { - CodexErrorInfo::ResponseStreamDisconnected { http_status_code } - } - CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { - CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } - } - CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { - CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - } - } - CoreCodexErrorInfo::Other => CodexErrorInfo::Other, - } - } -} - -impl From for NonSteerableTurnKind { - fn from(value: CoreNonSteerableTurnKind) -> Self { - match value { - CoreNonSteerableTurnKind::Review => Self::Review, - CoreNonSteerableTurnKind::Compact => Self::Compact, - } - } -} - -#[derive( - Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum AskForApproval { - #[serde(rename = "untrusted")] - #[ts(rename = "untrusted")] - UnlessTrusted, - OnFailure, - OnRequest, - #[experimental("askForApproval.granular")] - Granular { - sandbox_approval: bool, - rules: bool, - #[serde(default)] - skill_approval: bool, - #[serde(default)] - request_permissions: bool, - mcp_elicitations: bool, - }, - Never, -} - -impl AskForApproval { - pub fn to_core(self) -> CoreAskForApproval { - match self { - AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, - AskForApproval::OnFailure => CoreAskForApproval::OnFailure, - AskForApproval::OnRequest => CoreAskForApproval::OnRequest, - AskForApproval::Granular { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - } => CoreAskForApproval::Granular(CoreGranularApprovalConfig { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - }), - AskForApproval::Never => CoreAskForApproval::Never, - } - } -} - -impl From for AskForApproval { - fn from(value: CoreAskForApproval) -> Self { - match value { - CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, - CoreAskForApproval::OnFailure => AskForApproval::OnFailure, - CoreAskForApproval::OnRequest => AskForApproval::OnRequest, - CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { - sandbox_approval: granular_config.sandbox_approval, - rules: granular_config.rules, - skill_approval: granular_config.skill_approval, - request_permissions: granular_config.request_permissions, - mcp_elicitations: granular_config.mcp_elicitations, - }, - CoreAskForApproval::Never => AskForApproval::Never, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] -#[ts( - type = r#""user" | "auto_review" | "guardian_subagent""#, - export_to = "v2/" -)] -/// Configures who approval requests are routed to for review. Examples -/// include sandbox escapes, blocked network access, MCP approval prompts, and -/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully -/// prompted subagent to gather relevant context and apply a risk-based -/// decision framework before approving or denying the request. -pub enum ApprovalsReviewer { - #[serde(rename = "user")] - User, - #[serde(rename = "guardian_subagent", alias = "auto_review")] - AutoReview, -} - -impl JsonSchema for ApprovalsReviewer { - fn schema_name() -> String { - "ApprovalsReviewer".to_string() - } - - fn json_schema(_generator: &mut SchemaGenerator) -> Schema { - string_enum_schema_with_description( - &["user", "auto_review", "guardian_subagent"], - "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", - ) - } -} - -fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { - let mut schema = SchemaObject { - instance_type: Some(InstanceType::String.into()), - metadata: Some(Box::new(Metadata { - description: Some(description.to_string()), - ..Default::default() - })), - ..Default::default() - }; - schema.enum_values = Some( - values - .iter() - .map(|value| JsonValue::String((*value).to_string())) - .collect(), - ); - Schema::Object(schema) -} - -impl ApprovalsReviewer { - pub fn to_core(self) -> CoreApprovalsReviewer { - match self { - ApprovalsReviewer::User => CoreApprovalsReviewer::User, - ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, - } - } -} - -impl From for ApprovalsReviewer { - fn from(value: CoreApprovalsReviewer) -> Self { - match value { - CoreApprovalsReviewer::User => ApprovalsReviewer::User, - CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum SandboxMode { - ReadOnly, - WorkspaceWrite, - DangerFullAccess, -} - -impl SandboxMode { - pub fn to_core(self) -> CoreSandboxMode { - match self { - SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, - SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, - SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, - } - } -} - -impl From for SandboxMode { - fn from(value: CoreSandboxMode) -> Self { - match value { - CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, - CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, - CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, - } - } -} - -v2_enum_from_core!( - pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { - Inline, Detached - } -); - -v2_enum_from_core!( - pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { - Unsupported, - NotLoggedIn, - BearerToken, - OAuth - } -); - -v2_enum_from_core!( - pub enum ModelRerouteReason from CoreModelRerouteReason { - HighRiskCyberActivity - } -); - -v2_enum_from_core!( - pub enum ModelVerification from CoreModelVerification { - TrustedAccessForCyber - } -); - -v2_enum_from_core!( - pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop - } -); - -v2_enum_from_core!( - pub enum HookHandlerType from CoreHookHandlerType { - Command, Prompt, Agent - } -); - -v2_enum_from_core!( - pub enum HookExecutionMode from CoreHookExecutionMode { - Sync, Async - } -); - -v2_enum_from_core!( - pub enum HookScope from CoreHookScope { - Thread, Turn - } -); - -v2_enum_from_core!( - pub enum HookSource from CoreHookSource { - System, - User, - Project, - Mdm, - SessionFlags, - Plugin, - CloudRequirements, - LegacyManagedConfigFile, - LegacyManagedConfigMdm, - Unknown, - } -); - -fn default_hook_source() -> HookSource { - HookSource::Unknown -} - -v2_enum_from_core!( - pub enum HookRunStatus from CoreHookRunStatus { - Running, Completed, Failed, Blocked, Stopped - } -); - -v2_enum_from_core!( - pub enum HookOutputEntryKind from CoreHookOutputEntryKind { - Warning, Stop, Feedback, Context, Error - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum ThreadStartSource { - Startup, - Clear, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookOutputEntry { - pub kind: HookOutputEntryKind, - pub text: String, -} - -impl From for HookOutputEntry { - fn from(value: CoreHookOutputEntry) -> Self { - Self { - kind: value.kind.into(), - text: value.text, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookRunSummary { - pub id: String, - pub event_name: HookEventName, - pub handler_type: HookHandlerType, - pub execution_mode: HookExecutionMode, - pub scope: HookScope, - pub source_path: AbsolutePathBuf, - #[serde(default = "default_hook_source")] - pub source: HookSource, - pub display_order: i64, - pub status: HookRunStatus, - pub status_message: Option, - pub started_at: i64, - pub completed_at: Option, - pub duration_ms: Option, - pub entries: Vec, -} - -impl From for HookRunSummary { - fn from(value: CoreHookRunSummary) -> Self { - Self { - id: value.id, - event_name: value.event_name.into(), - handler_type: value.handler_type.into(), - execution_mode: value.execution_mode.into(), - scope: value.scope.into(), - source_path: value.source_path, - source: value.source.into(), - display_order: value.display_order, - status: value.status.into(), - status_message: value.status_message, - started_at: value.started_at, - completed_at: value.completed_at, - duration_ms: value.duration_ms, - entries: value.entries.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ConfigLayerSource { - /// Managed preferences layer delivered by MDM (macOS only). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Mdm { - domain: String, - key: String, - }, - - /// Managed config layer from a file (usually `managed_config.toml`). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - System { - /// This is the path to the system config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// User config layer from $CODEX_HOME/config.toml. This layer is special - /// in that it is expected to be: - /// - writable by the user - /// - generally outside the workspace directory - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - User { - /// This is the path to the user's config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// Path to a .codex/ folder within a project. There could be multiple of - /// these between `cwd` and the project/repo root. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Project { - dot_codex_folder: AbsolutePathBuf, - }, - - /// Session-layer overrides supplied via `-c`/`--config`. - SessionFlags, - - /// `managed_config.toml` was designed to be a config that was loaded - /// as the last layer on top of everything else. This scheme did not quite - /// work out as intended, but we keep this variant as a "best effort" while - /// we phase out `managed_config.toml` in favor of `requirements.toml`. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - LegacyManagedConfigTomlFromFile { - file: AbsolutePathBuf, - }, - - LegacyManagedConfigTomlFromMdm, -} - -impl ConfigLayerSource { - /// A settings from a layer with a higher precedence will override a setting - /// from a layer with a lower precedence. - pub fn precedence(&self) -> i16 { - match self { - ConfigLayerSource::Mdm { .. } => 0, - ConfigLayerSource::System { .. } => 10, - ConfigLayerSource::User { .. } => 20, - ConfigLayerSource::Project { .. } => 25, - ConfigLayerSource::SessionFlags => 30, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, - ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, - } - } -} - -/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from -/// layer `A` will be overridden by settings from layer `B`. -impl PartialOrd for ConfigLayerSource { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.precedence().cmp(&other.precedence())) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct SandboxWorkspaceWrite { - #[serde(default)] - pub writable_roots: Vec, - #[serde(default)] - pub network_access: bool, - #[serde(default)] - pub exclude_tmpdir_env_var: bool, - #[serde(default)] - pub exclude_slash_tmp: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ToolsV2 { - pub web_search: Option, - pub view_image: Option, -} - -#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolSpec { - #[ts(optional)] - pub namespace: Option, - pub name: String, - pub description: String, - pub input_schema: JsonValue, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub defer_loading: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DynamicToolSpecDe { - namespace: Option, - name: String, - description: String, - input_schema: JsonValue, - defer_loading: Option, - expose_to_context: Option, -} - -impl<'de> Deserialize<'de> for DynamicToolSpec { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let DynamicToolSpecDe { - namespace, - name, - description, - input_schema, - defer_loading, - expose_to_context, - } = DynamicToolSpecDe::deserialize(deserializer)?; - - Ok(Self { - namespace, - name, - description, - input_schema, - defer_loading: defer_loading - .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), - }) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ProfileV2 { - pub model: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional profile-level override for where approval requests - /// are routed for review. If omitted, the enclosing config default is - /// used. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub service_tier: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub web_search: Option, - pub tools: Option, - pub chatgpt_base_url: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AnalyticsConfig { - pub enabled: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum AppToolApproval { - Auto, - Prompt, - Approve, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppsDefaultConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, - #[serde(default = "default_enabled")] - pub destructive_enabled: bool, - #[serde(default = "default_enabled")] - pub open_world_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppToolConfig { - pub enabled: Option, - pub approval_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppToolsConfig { - #[serde(default, flatten)] - pub tools: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, - pub destructive_enabled: Option, - pub open_world_enabled: Option, - pub default_tools_approval_mode: Option, - pub default_tools_enabled: Option, - pub tools: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppsConfig { - #[serde(default, rename = "_default")] - pub default: Option, - #[serde(default, flatten)] - pub apps: HashMap, -} - -const fn default_enabled() -> bool { - true -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct Config { - pub model: Option, - pub review_model: Option, - pub model_context_window: Option, - pub model_auto_compact_token_limit: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional default for where approval requests are routed for - /// review. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub sandbox_mode: Option, - pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, - pub forced_login_method: Option, - pub web_search: Option, - pub tools: Option, - pub profile: Option, - #[experimental(nested)] - #[serde(default)] - pub profiles: HashMap, - pub instructions: Option, - pub developer_instructions: Option, - pub compact_prompt: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub service_tier: Option, - pub analytics: Option, - #[experimental("config/read.apps")] - #[serde(default)] - pub apps: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayerMetadata { - pub name: ConfigLayerSource, - pub version: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayer { - pub name: ConfigLayerSource, - pub version: String, - pub config: JsonValue, - #[serde(skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum MergeStrategy { - Replace, - Upsert, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WriteStatus { - Ok, - OkOverridden, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct OverriddenMetadata { - pub message: String, - pub overriding_layer: ConfigLayerMetadata, - pub effective_value: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWriteResponse { - pub status: WriteStatus, - pub version: String, - /// Canonical path to the config file that was written. - pub file_path: AbsolutePathBuf, - pub overridden_metadata: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ConfigWriteErrorCode { - ConfigLayerReadonly, - ConfigVersionConflict, - ConfigValidationError, - ConfigPathNotFound, - ConfigSchemaUnknownKey, - UserLayerNotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadParams { - #[serde(default)] - pub include_layers: bool, - /// Optional working directory to resolve project config layers. If specified, - /// return the effective config as seen from that directory (i.e., including any - /// project layers between `cwd` and the project/repo root). - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadResponse { - #[experimental(nested)] - pub config: Config, - pub origins: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub layers: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirements { - #[experimental(nested)] - pub allowed_approval_policies: Option>, - #[experimental("configRequirements/read.allowedApprovalsReviewers")] - pub allowed_approvals_reviewers: Option>, - pub allowed_sandbox_modes: Option>, - pub allowed_web_search_modes: Option>, - pub feature_requirements: Option>, - #[experimental("configRequirements/read.hooks")] - pub hooks: Option, - pub enforce_residency: Option, - #[experimental("configRequirements/read.network")] - pub network: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ManagedHooksRequirements { - pub managed_dir: Option, - pub windows_managed_dir: Option, - #[serde(rename = "PreToolUse")] - #[ts(rename = "PreToolUse")] - pub pre_tool_use: Vec, - #[serde(rename = "PermissionRequest")] - #[ts(rename = "PermissionRequest")] - pub permission_request: Vec, - #[serde(rename = "PostToolUse")] - #[ts(rename = "PostToolUse")] - pub post_tool_use: Vec, - #[serde(rename = "SessionStart")] - #[ts(rename = "SessionStart")] - pub session_start: Vec, - #[serde(rename = "UserPromptSubmit")] - #[ts(rename = "UserPromptSubmit")] - pub user_prompt_submit: Vec, - #[serde(rename = "Stop")] - #[ts(rename = "Stop")] - pub stop: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfiguredHookMatcherGroup { - pub matcher: Option, - pub hooks: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type")] -#[ts(tag = "type", export_to = "v2/")] -pub enum ConfiguredHookHandler { - #[serde(rename = "command")] - #[ts(rename = "command")] - Command { - command: String, - #[serde(rename = "timeoutSec")] - #[ts(rename = "timeoutSec")] - timeout_sec: Option, - r#async: bool, - #[serde(rename = "statusMessage")] - #[ts(rename = "statusMessage")] - status_message: Option, - }, - #[serde(rename = "prompt")] - #[ts(rename = "prompt")] - Prompt {}, - #[serde(rename = "agent")] - #[ts(rename = "agent")] - Agent {}, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkRequirements { - pub enabled: Option, - pub http_port: Option, - pub socks_port: Option, - pub allow_upstream_proxy: Option, - pub dangerously_allow_non_loopback_proxy: Option, - pub dangerously_allow_all_unix_sockets: Option, - /// Canonical network permission map for `experimental_network`. - pub domains: Option>, - /// When true, only managed allowlist entries are respected while managed - /// network enforcement is active. - pub managed_allowed_domains_only: Option, - /// Legacy compatibility view derived from `domains`. - pub allowed_domains: Option>, - /// Legacy compatibility view derived from `domains`. - pub denied_domains: Option>, - /// Canonical unix socket permission map for `experimental_network`. - pub unix_sockets: Option>, - /// Legacy compatibility view derived from `unix_sockets`. - pub allow_unix_sockets: Option>, - pub allow_local_binding: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum NetworkDomainPermission { - Allow, - Deny, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum NetworkUnixSocketPermission { - Allow, - None, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ResidencyRequirement { - Us, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirementsReadResponse { - /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). - #[experimental(nested)] - pub requirements: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum ExternalAgentConfigMigrationItemType { - #[serde(rename = "AGENTS_MD")] - #[ts(rename = "AGENTS_MD")] - AgentsMd, - #[serde(rename = "CONFIG")] - #[ts(rename = "CONFIG")] - Config, - #[serde(rename = "SKILLS")] - #[ts(rename = "SKILLS")] - Skills, - #[serde(rename = "PLUGINS")] - #[ts(rename = "PLUGINS")] - Plugins, - #[serde(rename = "MCP_SERVER_CONFIG")] - #[ts(rename = "MCP_SERVER_CONFIG")] - McpServerConfig, - #[serde(rename = "SUBAGENTS")] - #[ts(rename = "SUBAGENTS")] - Subagents, - #[serde(rename = "HOOKS")] - #[ts(rename = "HOOKS")] - Hooks, - #[serde(rename = "COMMANDS")] - #[ts(rename = "COMMANDS")] - Commands, - #[serde(rename = "SESSIONS")] - #[ts(rename = "SESSIONS")] - Sessions, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginsMigration { - #[serde(rename = "marketplaceName")] - #[ts(rename = "marketplaceName")] - pub marketplace_name: String, - #[serde(rename = "pluginNames")] - #[ts(rename = "pluginNames")] - pub plugin_names: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SessionMigration { - pub path: PathBuf, - pub cwd: PathBuf, - pub title: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SubagentMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MigrationDetails { - #[serde(default)] - pub plugins: Vec, - #[serde(default)] - pub sessions: Vec, - #[serde(default)] - pub mcp_servers: Vec, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub subagents: Vec, - #[serde(default)] - pub commands: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigMigrationItem { - pub item_type: ExternalAgentConfigMigrationItemType, - pub description: String, - /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. - pub cwd: Option, - pub details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectResponse { - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub include_home: bool, - /// Zero or more working directories to include for repo-scoped detection. - #[ts(optional = nullable)] - pub cwds: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportParams { - pub migration_items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportCompletedNotification {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigValueWriteParams { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigBatchWriteParams { - pub edits: Vec, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, - /// When true, hot-reload the updated user config into all loaded threads after writing. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub reload_user_config: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigEdit { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionApprovalDecision { - /// User approved the command. - Accept, - /// User approved the command and future prompts in the same session-scoped - /// approval cache should run without prompting. - AcceptForSession, - /// User approved the command, and wants to apply the proposed execpolicy amendment so future - /// matching commands can run without prompting. - AcceptWithExecpolicyAmendment { - execpolicy_amendment: ExecPolicyAmendment, - }, - /// User chose a persistent network policy rule (allow/deny) for this host. - ApplyNetworkPolicyAmendment { - network_policy_amendment: NetworkPolicyAmendment, - }, - /// User denied the command. The agent will continue the turn. - Decline, - /// User denied the command. The turn will also be immediately interrupted. - Cancel, -} - -impl From for CommandExecutionApprovalDecision { - fn from(value: CoreReviewDecision) -> Self { - match value { - CoreReviewDecision::Approved => Self::Accept, - CoreReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment, - } => Self::AcceptWithExecpolicyAmendment { - execpolicy_amendment: proposed_execpolicy_amendment.into(), - }, - CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, - CoreReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => Self::ApplyNetworkPolicyAmendment { - network_policy_amendment: network_policy_amendment.into(), - }, - CoreReviewDecision::Abort => Self::Cancel, - CoreReviewDecision::Denied => Self::Decline, - CoreReviewDecision::TimedOut => Self::Decline, - } - } -} - -v2_enum_from_core! { - pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { - Http, - Https, - Socks5Tcp, - Socks5Udp, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkApprovalContext { - pub host: String, - pub protocol: NetworkApprovalProtocol, -} - -impl From for NetworkApprovalContext { - fn from(value: CoreNetworkApprovalContext) -> Self { - Self { - host: value.host, - protocol: value.protocol.into(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalFileSystemPermissions { - /// This will be removed in favor of `entries`. - pub read: Option>, - /// This will be removed in favor of `entries`. - pub write: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub glob_scan_max_depth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub entries: Option>, -} - -impl From for AdditionalFileSystemPermissions { - fn from(value: CoreFileSystemPermissions) -> Self { - if let Some((read, write)) = value.legacy_read_write_roots() { - let mut entries = Vec::with_capacity( - read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), - ); - if let Some(paths) = read.as_ref() { - entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { - path: FileSystemPath::Path { path: path.clone() }, - access: FileSystemAccessMode::Read, - })); - } - if let Some(paths) = write.as_ref() { - entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { - path: FileSystemPath::Path { path: path.clone() }, - access: FileSystemAccessMode::Write, - })); - } - Self { - read, - write, - glob_scan_max_depth: None, - entries: Some(entries), - } - } else { - Self { - read: None, - write: None, - glob_scan_max_depth: value.glob_scan_max_depth, - entries: Some( - value - .entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - ), - } - } - } -} - -impl From for CoreFileSystemPermissions { - fn from(value: AdditionalFileSystemPermissions) -> Self { - let mut permissions = if let Some(entries) = value.entries { - Self { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth: None, - } - } else { - CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) - }; - permissions.glob_scan_max_depth = value.glob_scan_max_depth; - permissions - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalNetworkPermissions { - pub enabled: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionProfileNetworkPermissions { - pub enabled: bool, -} - -impl From for AdditionalNetworkPermissions { - fn from(value: CoreNetworkPermissions) -> Self { - Self { - enabled: value.enabled, - } - } -} - -impl From for CoreNetworkPermissions { - fn from(value: AdditionalNetworkPermissions) -> Self { - Self { - enabled: value.enabled, - } - } -} - -impl From for PermissionProfileNetworkPermissions { - fn from(value: CoreNetworkSandboxPolicy) -> Self { - Self { - enabled: value.is_enabled(), - } - } -} - -impl From for CoreNetworkSandboxPolicy { - fn from(value: PermissionProfileNetworkPermissions) -> Self { - if value.enabled { - Self::Enabled - } else { - Self::Restricted - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct RequestPermissionProfile { - pub network: Option, - pub file_system: Option, -} - -impl From for RequestPermissionProfile { - fn from(value: CoreRequestPermissionProfile) -> Self { - Self { - network: value.network.map(AdditionalNetworkPermissions::from), - file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - } - } -} - -impl From for CoreRequestPermissionProfile { - fn from(value: RequestPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -v2_enum_from_core!( - pub enum FileSystemAccessMode from CoreFileSystemAccessMode { - Read, - Write, - None - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[ts(tag = "kind")] -#[ts(export_to = "v2/")] -pub enum FileSystemSpecialPath { - Root, - Minimal, - #[serde(alias = "current_working_directory")] - ProjectRoots { - subpath: Option, - }, - Tmpdir, - SlashTmp, - Unknown { - path: String, - subpath: Option, - }, -} - -impl From for FileSystemSpecialPath { - fn from(value: CoreFileSystemSpecialPath) -> Self { - match value { - CoreFileSystemSpecialPath::Root => Self::Root, - CoreFileSystemSpecialPath::Minimal => Self::Minimal, - CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, - CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, - CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, - CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, - } - } -} - -impl From for CoreFileSystemSpecialPath { - fn from(value: FileSystemSpecialPath) -> Self { - match value { - FileSystemSpecialPath::Root => Self::Root, - FileSystemSpecialPath::Minimal => Self::Minimal, - FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, - FileSystemSpecialPath::Tmpdir => Self::Tmpdir, - FileSystemSpecialPath::SlashTmp => Self::SlashTmp, - FileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum FileSystemPath { - Path { path: AbsolutePathBuf }, - GlobPattern { pattern: String }, - Special { value: FileSystemSpecialPath }, -} - -impl From for FileSystemPath { - fn from(value: CoreFileSystemPath) -> Self { - match value { - CoreFileSystemPath::Path { path } => Self::Path { path }, - CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, - CoreFileSystemPath::Special { value } => Self::Special { - value: value.into(), - }, - } - } -} - -impl From for CoreFileSystemPath { - fn from(value: FileSystemPath) -> Self { - match value { - FileSystemPath::Path { path } => Self::Path { path }, - FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, - FileSystemPath::Special { value } => Self::Special { - value: value.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileSystemSandboxEntry { - pub path: FileSystemPath, - pub access: FileSystemAccessMode, -} - -impl From for FileSystemSandboxEntry { - fn from(value: CoreFileSystemSandboxEntry) -> Self { - Self { - path: value.path.into(), - access: value.access.into(), - } - } -} - -impl From for CoreFileSystemSandboxEntry { - fn from(value: FileSystemSandboxEntry) -> Self { - Self { - path: value.path.into(), - access: value.access.to_core(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileFileSystemPermissions { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - glob_scan_max_depth: Option, - }, - Unrestricted, -} - -impl From for PermissionProfileFileSystemPermissions { - fn from(value: CoreManagedFileSystemPermissions) -> Self { - match value { - CoreManagedFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -impl From for CoreManagedFileSystemPermissions { - fn from(value: PermissionProfileFileSystemPermissions) -> Self { - match value { - PermissionProfileFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfile { - /// Codex owns sandbox construction for this profile. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Managed { - network: PermissionProfileNetworkPermissions, - file_system: PermissionProfileFileSystemPermissions, - }, - /// Do not apply an outer sandbox. - Disabled, - /// Filesystem isolation is enforced by an external caller. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - External { - network: PermissionProfileNetworkPermissions, - }, -} - -impl From for PermissionProfile { - fn from(value: CorePermissionProfile) -> Self { - match value { - CorePermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - network: network.into(), - file_system: file_system.into(), - }, - CorePermissionProfile::Disabled => Self::Disabled, - CorePermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -impl From for CorePermissionProfile { - fn from(value: PermissionProfile) -> Self { - match value { - PermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - file_system: file_system.into(), - network: network.into(), - }, - PermissionProfile::Disabled => Self::Disabled, - PermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ActivePermissionProfile { - /// Identifier from `default_permissions` or the implicit built-in default, - /// such as `:workspace` or a user-defined `[permissions.]` profile. - pub id: String, - /// Parent profile identifier once permissions profiles support - /// inheritance. This is currently always `null`. - #[serde(default)] - pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for ActivePermissionProfile { - fn from(value: CoreActivePermissionProfile) -> Self { - Self { - id: value.id, - extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), - } - } -} - -impl From for CoreActivePermissionProfile { - fn from(value: ActivePermissionProfile) -> Self { - Self { - id: value.id, - extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalPermissionProfile { - /// Partial overlay used for per-command permission requests. - pub network: Option, - pub file_system: Option, -} - -impl From for AdditionalPermissionProfile { - fn from(value: CoreAdditionalPermissionProfile) -> Self { - Self { - network: value.network.map(AdditionalNetworkPermissions::from), - file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - } - } -} - -impl From for CoreAdditionalPermissionProfile { - fn from(value: AdditionalPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GrantedPermissionProfile { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub network: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub file_system: Option, -} - -impl From for CoreAdditionalPermissionProfile { - fn from(value: GrantedPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum FileChangeApprovalDecision { - /// User approved the file changes. - Accept, - /// User approved the file changes and future changes to the same files should run without prompting. - AcceptForSession, - /// User denied the file changes. The agent will continue the turn. - Decline, - /// User denied the file changes. The turn will also be immediately interrupted. - Cancel, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum NetworkAccess { - #[default] - Restricted, - Enabled, -} - -#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum SandboxPolicy { - DangerFullAccess, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ReadOnly { - #[serde(default)] - network_access: bool, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, - #[serde(default)] - network_access: bool, - #[serde(default)] - exclude_tmpdir_env_var: bool, - #[serde(default)] - exclude_slash_tmp: bool, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum SandboxPolicyDeserialize { - DangerFullAccess, - #[serde(rename_all = "camelCase")] - ReadOnly { - #[serde(default)] - network_access: bool, - #[serde(default)] - access: Option, - }, - #[serde(rename_all = "camelCase")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, - #[serde(rename_all = "camelCase")] - WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, - #[serde(default)] - read_only_access: Option, - #[serde(default)] - network_access: bool, - #[serde(default)] - exclude_tmpdir_env_var: bool, - #[serde(default)] - exclude_slash_tmp: bool, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum LegacyReadOnlyAccess { - FullAccess, - Restricted, -} - -impl<'de> Deserialize<'de> for SandboxPolicy { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - match SandboxPolicyDeserialize::deserialize(deserializer)? { - SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), - SandboxPolicyDeserialize::ReadOnly { - network_access, - access, - } => { - if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { - return Err(serde::de::Error::custom( - "readOnly.access is no longer supported; use permissionProfile for restricted reads", - )); - } - Ok(SandboxPolicy::ReadOnly { network_access }) - } - SandboxPolicyDeserialize::ExternalSandbox { network_access } => { - Ok(SandboxPolicy::ExternalSandbox { network_access }) - } - SandboxPolicyDeserialize::WorkspaceWrite { - writable_roots, - read_only_access, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => { - if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { - return Err(serde::de::Error::custom( - "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", - )); - } - Ok(SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }) - } - } - } -} - -impl SandboxPolicy { - pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { - match self { - SandboxPolicy::DangerFullAccess => { - codex_protocol::protocol::SandboxPolicy::DangerFullAccess - } - SandboxPolicy::ReadOnly { network_access } => { - codex_protocol::protocol::SandboxPolicy::ReadOnly { - network_access: *network_access, - } - } - SandboxPolicy::ExternalSandbox { network_access } => { - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: match network_access { - NetworkAccess::Restricted => CoreNetworkAccess::Restricted, - NetworkAccess::Enabled => CoreNetworkAccess::Enabled, - }, - } - } - SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - }, - } - } -} - -impl From for SandboxPolicy { - fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { - match value { - codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { - SandboxPolicy::DangerFullAccess - } - codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { - SandboxPolicy::ReadOnly { network_access } - } - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { - SandboxPolicy::ExternalSandbox { - network_access: match network_access { - CoreNetworkAccess::Restricted => NetworkAccess::Restricted, - CoreNetworkAccess::Enabled => NetworkAccess::Enabled, - }, - } - } - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(transparent)] -#[ts(type = "Array", export_to = "v2/")] -pub struct ExecPolicyAmendment { - pub command: Vec, -} - -impl ExecPolicyAmendment { - pub fn into_core(self) -> CoreExecPolicyAmendment { - CoreExecPolicyAmendment::new(self.command) - } -} - -impl From for ExecPolicyAmendment { - fn from(value: CoreExecPolicyAmendment) -> Self { - Self { - command: value.command().to_vec(), - } - } -} - -v2_enum_from_core!( - pub enum NetworkPolicyRuleAction from CoreNetworkPolicyRuleAction { - Allow, Deny - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkPolicyAmendment { - pub host: String, - pub action: NetworkPolicyRuleAction, -} - -impl NetworkPolicyAmendment { - pub fn into_core(self) -> CoreNetworkPolicyAmendment { - CoreNetworkPolicyAmendment { - host: self.host, - action: self.action.to_core(), - } - } -} - -impl From for NetworkPolicyAmendment { - fn from(value: CoreNetworkPolicyAmendment) -> Self { - Self { - host: value.host, - action: NetworkPolicyRuleAction::from(value.action), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum CommandAction { - Read { - command: String, - name: String, - path: AbsolutePathBuf, - }, - ListFiles { - command: String, - path: Option, - }, - Search { - command: String, - query: Option, - path: Option, - }, - Unknown { - command: String, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -#[derive(Default)] -pub enum SessionSource { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - #[default] - VsCode, - Exec, - AppServer, - Custom(String), - SubAgent(CoreSubAgentSource), - #[serde(other)] - Unknown, -} - -impl From for SessionSource { - fn from(value: CoreSessionSource) -> Self { - match value { - CoreSessionSource::Cli => SessionSource::Cli, - CoreSessionSource::VSCode => SessionSource::VsCode, - CoreSessionSource::Exec => SessionSource::Exec, - CoreSessionSource::Mcp => SessionSource::AppServer, - CoreSessionSource::Custom(source) => SessionSource::Custom(source), - // We do not want to render those at the app-server level. - CoreSessionSource::Internal(_) => SessionSource::Unknown, - CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), - CoreSessionSource::Unknown => SessionSource::Unknown, - } - } -} - -impl From for CoreSessionSource { - fn from(value: SessionSource) -> Self { - match value { - SessionSource::Cli => CoreSessionSource::Cli, - SessionSource::VsCode => CoreSessionSource::VSCode, - SessionSource::Exec => CoreSessionSource::Exec, - SessionSource::AppServer => CoreSessionSource::Mcp, - SessionSource::Custom(source) => CoreSessionSource::Custom(source), - SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), - SessionSource::Unknown => CoreSessionSource::Unknown, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GitInfo { - pub sha: Option, - pub branch: Option, - pub origin_url: Option, -} - -impl CommandAction { - pub fn into_core(self) -> CoreParsedCommand { - match self { - CommandAction::Read { - command: cmd, - name, - path, - } => CoreParsedCommand::Read { - cmd, - name, - path: path.into_path_buf(), - }, - CommandAction::ListFiles { command: cmd, path } => { - CoreParsedCommand::ListFiles { cmd, path } - } - CommandAction::Search { - command: cmd, - query, - path, - } => CoreParsedCommand::Search { cmd, query, path }, - CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, - } - } -} - -impl CommandAction { - pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { - match value { - CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { - command: cmd, - name, - path: cwd.join(path), - }, - CoreParsedCommand::ListFiles { cmd, path } => { - CommandAction::ListFiles { command: cmd, path } - } - CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { - command: cmd, - query, - path, - }, - CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum Account { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, - - #[serde(rename = "amazonBedrock", rename_all = "camelCase")] - #[ts(rename = "amazonBedrock", rename_all = "camelCase")] - AmazonBedrock {}, -} - -impl From for Account { - fn from(account: ProviderAccount) -> Self { - match account { - ProviderAccount::ApiKey => Self::ApiKey {}, - ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, - ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(tag = "type")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountParams { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey { - #[serde(rename = "apiKey")] - #[ts(rename = "apiKey")] - api_key: String, - }, - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - codex_streamlined_login: bool, - }, - #[serde(rename = "chatgptDeviceCode")] - #[ts(rename = "chatgptDeviceCode")] - ChatgptDeviceCode, - /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. - /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. - #[experimental("account/login/start.chatgptAuthTokens")] - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens { - /// Access token (JWT) supplied by the client. - /// This token is used for backend API requests and email extraction. - access_token: String, - /// Workspace/account identifier supplied by the client. - chatgpt_account_id: String, - /// Optional plan type supplied by the client. - /// - /// When `null`, Codex attempts to derive the plan type from access-token - /// claims. If unavailable, the plan defaults to `unknown`. - #[ts(optional = nullable)] - chatgpt_plan_type: Option, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountResponse { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - login_id: String, - /// URL the client should open in a browser to initiate the OAuth flow. - auth_url: String, - }, - #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] - #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] - ChatgptDeviceCode { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - login_id: String, - /// URL the client should open in a browser to complete device code authorization. - verification_url: String, - /// One-time code the user must enter after signing in. - user_code: String, - }, - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens {}, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountParams { - pub login_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CancelLoginAccountStatus { - Canceled, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountResponse { - pub status: CancelLoginAccountStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct LogoutAccountResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ChatgptAuthTokensRefreshReason { - /// Codex attempted a backend request and received `401 Unauthorized`. - Unauthorized, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshParams { - pub reason: ChatgptAuthTokensRefreshReason, - /// Workspace/account identifier that Codex was previously using. - /// - /// Clients that manage multiple accounts/workspaces can use this as a hint - /// to refresh the token for the correct workspace. - /// - /// This may be `null` when the prior auth state did not include a workspace - /// identifier (`chatgpt_account_id`). - #[ts(optional = nullable)] - pub previous_account_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshResponse { - pub access_token: String, - pub chatgpt_account_id: String, - pub chatgpt_plan_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountRateLimitsResponse { - /// Backward-compatible single-bucket view; mirrors the historical payload. - pub rate_limits: RateLimitSnapshot, - /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). - pub rate_limits_by_limit_id: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SendAddCreditsNudgeEmailParams { - pub credit_type: AddCreditsNudgeCreditType, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum AddCreditsNudgeCreditType { - Credits, - UsageLimit, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SendAddCreditsNudgeEmailResponse { - pub status: AddCreditsNudgeEmailStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum AddCreditsNudgeEmailStatus { - Sent, - CooldownActive, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountParams { - /// When `true`, requests a proactive token refresh before returning. - /// - /// In managed auth mode this triggers the normal refresh-token flow. In - /// external auth mode this flag is ignored. Clients should refresh tokens - /// themselves and call `account/login/start` with `chatgptAuthTokens`. - #[serde(default)] - pub refresh_token: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountResponse { - pub account: Option, - pub requires_openai_auth: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelProviderCapabilitiesReadParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelProviderCapabilitiesReadResponse { - pub namespace_tools: bool, - pub image_generation: bool, - pub web_search: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// When true, include models that are hidden from the default picker list. - #[ts(optional = nullable)] - pub include_hidden: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelAvailabilityNux { - pub message: String, -} - -impl From for ModelAvailabilityNux { - fn from(value: CoreModelAvailabilityNux) -> Self { - Self { - message: value.message, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Model { - pub id: String, - pub model: String, - pub upgrade: Option, - pub upgrade_info: Option, - pub availability_nux: Option, - pub display_name: String, - pub description: String, - pub hidden: bool, - pub supported_reasoning_efforts: Vec, - pub default_reasoning_effort: ReasoningEffort, - #[serde(default = "default_input_modalities")] - pub input_modalities: Vec, - #[serde(default)] - pub supports_personality: bool, - #[serde(default)] - pub additional_speed_tiers: Vec, - // Only one model should be marked as default. - pub is_default: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelUpgradeInfo { - pub model: String, - pub upgrade_copy: Option, - pub model_link: Option, - pub migration_markdown: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningEffortOption { - pub reasoning_effort: ReasoningEffort, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -/// EXPERIMENTAL - list collaboration mode presets. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListParams {} - -/// EXPERIMENTAL - collaboration mode preset metadata for clients. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeMask { - pub name: String, - pub mode: Option, - pub model: Option, - #[serde(rename = "reasoning_effort")] - #[ts(rename = "reasoning_effort")] - pub reasoning_effort: Option>, -} - -impl From for CollaborationModeMask { - fn from(value: CoreCollaborationModeMask) -> Self { - Self { - name: value.name, - mode: value.mode, - model: value.model, - reasoning_effort: value.reasoning_effort, - } - } -} - -/// EXPERIMENTAL - collaboration mode presets response. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ExperimentalFeatureStage { - /// Feature is available for user testing and feedback. - Beta, - /// Feature is still being built and not ready for broad use. - UnderDevelopment, - /// Feature is production-ready. - Stable, - /// Feature is deprecated and should be avoided. - Deprecated, - /// Feature flag is retained only for backwards compatibility. - Removed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeature { - /// Stable key used in config.toml and CLI flag toggles. - pub name: String, - /// Lifecycle stage of this feature flag. - pub stage: ExperimentalFeatureStage, - /// User-facing display name shown in the experimental features UI. - /// Null when this feature is not in beta. - pub display_name: Option, - /// Short summary describing what the feature does. - /// Null when this feature is not in beta. - pub description: Option, - /// Announcement copy shown to users when the feature is introduced. - /// Null when this feature is not in beta. - pub announcement: Option, - /// Whether this feature is currently enabled in the loaded config. - pub enabled: bool, - /// Whether this feature is enabled by default. - pub default_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureEnablementSetParams { - /// Process-wide runtime feature enablement keyed by canonical feature name. - /// - /// Only named features are updated. Omitted features are left unchanged. - /// Send an empty map for a no-op. - pub enablement: std::collections::BTreeMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureEnablementSetResponse { - /// Feature enablement entries updated by this request. - pub enablement: std::collections::BTreeMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a server-defined value. - #[ts(optional = nullable)] - pub limit: Option, - /// Controls how much MCP inventory data to fetch for each server. - /// Defaults to `Full` when omitted. - #[ts(optional = nullable)] - pub detail: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum McpServerStatusDetail { - Full, - ToolsAndAuthOnly, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerStatus { - pub name: String, - pub tools: std::collections::HashMap, - pub resources: Vec, - pub resource_templates: Vec, - pub auth_status: McpAuthStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpResourceReadParams { - #[ts(optional = nullable)] - pub thread_id: Option, - pub server: String, - pub uri: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpResourceReadResponse { - pub contents: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerToolCallParams { - pub thread_id: String, - pub server: String, - pub tool: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub arguments: Option, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub meta: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerToolCallResponse { - pub content: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub structured_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub is_error: Option, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub meta: Option, -} - -impl From for McpServerToolCallResponse { - fn from(result: CoreMcpCallToolResult) -> Self { - Self { - content: result.content, - structured_content: result.structured_content, - is_error: result.is_error, - meta: result.meta, - } - } -} - -impl From for McpToolCallResult { - fn from(result: CoreMcpCallToolResult) -> Self { - Self { - content: result.content, - structured_content: result.structured_content, - meta: result.meta, - } - } -} - -impl From for McpToolCallError { - fn from(error: CoreMcpToolCallError) -> Self { - Self { - message: error.message, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - list available apps/connectors. -pub struct AppsListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional thread id used to evaluate app feature gating from that thread's config. - #[ts(optional = nullable)] - pub thread_id: Option, - /// When true, bypass app caches and fetch the latest data from sources. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_refetch: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata returned by app-list APIs. -pub struct AppBranding { - pub category: Option, - pub developer: Option, - pub website: Option, - pub privacy_policy: Option, - pub terms_of_service: Option, - pub is_discoverable_app: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppReview { - pub status: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppScreenshot { - pub url: Option, - #[serde(alias = "file_id")] - pub file_id: Option, - #[serde(alias = "user_prompt")] - pub user_prompt: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppMetadata { - pub review: Option, - pub categories: Option>, - pub sub_categories: Option>, - pub seo_description: Option, - pub screenshots: Option>, - pub developer: Option, - pub version: Option, - pub version_id: Option, - pub version_notes: Option, - pub first_party_type: Option, - pub first_party_requires_install: Option, - pub show_in_composer_when_unlinked: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata returned by app-list APIs. -pub struct AppInfo { - pub id: String, - pub name: String, - pub description: Option, - pub logo_url: Option, - pub logo_url_dark: Option, - pub distribution_channel: Option, - pub branding: Option, - pub app_metadata: Option, - pub labels: Option>, - pub install_url: Option, - #[serde(default)] - pub is_accessible: bool, - /// Whether this app is enabled in config.toml. - /// Example: - /// ```toml - /// [apps.bad_app] - /// enabled = false - /// ``` - #[serde(default = "default_enabled")] - pub is_enabled: bool, - #[serde(default)] - pub plugin_display_names: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata summary for plugin responses. -pub struct AppSummary { - pub id: String, - pub name: String, - pub description: Option, - pub install_url: Option, - pub needs_auth: bool, -} - -impl From for AppSummary { - fn from(value: AppInfo) -> Self { - Self { - id: value.id, - name: value.name, - description: value.description, - install_url: value.install_url, - needs_auth: false, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app list response. -pub struct AppsListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - notification emitted when the app list changes. -pub struct AppListUpdatedNotification { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginParams { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub scopes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub timeout_secs: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginResponse { - pub authorization_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadParams { - pub classification: String, - #[ts(optional = nullable)] - pub reason: Option, - #[ts(optional = nullable)] - pub thread_id: Option, - pub include_logs: bool, - #[ts(optional = nullable)] - pub extra_log_files: Option>, - #[ts(optional = nullable)] - pub tags: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadResponse { - pub thread_id: String, -} - -/// Device-key algorithm reported at enrollment and signing boundaries. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyAlgorithm { - EcdsaP256Sha256, -} - -/// Platform protection class for a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyProtectionClass { - HardwareSecureEnclave, - HardwareTpm, - OsProtectedNonextractable, -} - -/// Protection policy for creating or loading a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyProtectionPolicy { - HardwareOnly, - AllowOsProtectedNonextractable, -} - -/// Create a controller-local device key with a random key id. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyCreateParams { - /// Defaults to `hardware_only` when omitted. - #[ts(optional = nullable)] - pub protection_policy: Option, - pub account_user_id: String, - pub client_id: String, -} - -/// Device-key metadata and public key returned by create/public APIs. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyCreateResponse { - pub key_id: String, - /// SubjectPublicKeyInfo DER encoded as base64. - pub public_key_spki_der_base64: String, - pub algorithm: DeviceKeyAlgorithm, - pub protection_class: DeviceKeyProtectionClass, -} - -/// Fetch a controller-local device key public key by id. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyPublicParams { - pub key_id: String, -} - -/// Device-key public metadata returned by `device/key/public`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyPublicResponse { - pub key_id: String, - /// SubjectPublicKeyInfo DER encoded as base64. - pub public_key_spki_der_base64: String, - pub algorithm: DeviceKeyAlgorithm, - pub protection_class: DeviceKeyProtectionClass, -} - -/// Current remote-control connection status and environment id exposed to clients. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RemoteControlStatusChangedNotification { - pub status: RemoteControlConnectionStatus, - pub environment_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum RemoteControlConnectionStatus { - Disabled, - Connecting, - Connected, - Errored, -} - -/// Audience for a remote-control client connection device-key proof. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum RemoteControlClientConnectionAudience { - RemoteControlClientWebsocket, -} - -/// Audience for a remote-control client enrollment device-key proof. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum RemoteControlClientEnrollmentAudience { - RemoteControlClientEnrollment, -} - -/// Structured payloads accepted by `device/key/sign`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", export_to = "v2/")] -pub enum DeviceKeySignPayload { - /// Payload bound to one remote-control controller websocket `/client` connection challenge. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RemoteControlClientConnection { - nonce: String, - audience: RemoteControlClientConnectionAudience, - /// Backend-issued websocket session id that this proof authorizes. - session_id: String, - /// Origin of the backend endpoint that issued the challenge and will verify this proof. - target_origin: String, - /// Websocket route path that this proof authorizes. - target_path: String, - account_user_id: String, - client_id: String, - /// Remote-control token expiration as Unix seconds. - #[ts(type = "number")] - token_expires_at: i64, - /// SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url. - token_sha256_base64url: String, - /// Must contain exactly `remote_control_controller_websocket`. - scopes: Vec, - }, - /// Payload bound to a remote-control client `/client/enroll` ownership challenge. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RemoteControlClientEnrollment { - nonce: String, - audience: RemoteControlClientEnrollmentAudience, - /// Backend-issued enrollment challenge id that this proof authorizes. - challenge_id: String, - /// Origin of the backend endpoint that issued the challenge and will verify this proof. - target_origin: String, - /// HTTP route path that this proof authorizes. - target_path: String, - account_user_id: String, - client_id: String, - /// SHA-256 of the requested device identity operation, encoded as unpadded base64url. - device_identity_sha256_base64url: String, - /// Enrollment challenge expiration as Unix seconds. - #[ts(type = "number")] - challenge_expires_at: i64, - }, -} - -/// Sign an accepted structured payload with a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeySignParams { - pub key_id: String, - pub payload: DeviceKeySignPayload, -} - -/// ASN.1 DER signature returned by `device/key/sign`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeySignResponse { - /// ECDSA signature DER encoded as base64. - pub signature_der_base64: String, - /// Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte - /// string directly and must not reserialize `payload`. - pub signed_payload_base64: String, - pub algorithm: DeviceKeyAlgorithm, -} - -/// Read a file from the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadFileParams { - /// Absolute path to read. - pub path: AbsolutePathBuf, -} - -/// Base64-encoded file contents returned by `fs/readFile`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadFileResponse { - /// File contents encoded as base64. - pub data_base64: String, -} - -/// Write a file on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWriteFileParams { - /// Absolute path to write. - pub path: AbsolutePathBuf, - /// File contents encoded as base64. - pub data_base64: String, -} - -/// Successful response for `fs/writeFile`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWriteFileResponse {} - -/// Create a directory on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCreateDirectoryParams { - /// Absolute directory path to create. - pub path: AbsolutePathBuf, - /// Whether parent directories should also be created. Defaults to `true`. - #[ts(optional = nullable)] - pub recursive: Option, -} - -/// Successful response for `fs/createDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCreateDirectoryResponse {} - -/// Request metadata for an absolute path. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsGetMetadataParams { - /// Absolute path to inspect. - pub path: AbsolutePathBuf, -} - -/// Metadata returned by `fs/getMetadata`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsGetMetadataResponse { - /// Whether the path resolves to a directory. - pub is_directory: bool, - /// Whether the path resolves to a regular file. - pub is_file: bool, - /// Whether the path itself is a symbolic link. - pub is_symlink: bool, - /// File creation time in Unix milliseconds when available, otherwise `0`. - #[ts(type = "number")] - pub created_at_ms: i64, - /// File modification time in Unix milliseconds when available, otherwise `0`. - #[ts(type = "number")] - pub modified_at_ms: i64, -} - -/// List direct child names for a directory. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryParams { - /// Absolute directory path to read. - pub path: AbsolutePathBuf, -} - -/// A directory entry returned by `fs/readDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryEntry { - /// Direct child entry name only, not an absolute or relative path. - pub file_name: String, - /// Whether this entry resolves to a directory. - pub is_directory: bool, - /// Whether this entry resolves to a regular file. - pub is_file: bool, -} - -/// Directory entries returned by `fs/readDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryResponse { - /// Direct child entries in the requested directory. - pub entries: Vec, -} - -/// Remove a file or directory tree from the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsRemoveParams { - /// Absolute path to remove. - pub path: AbsolutePathBuf, - /// Whether directory removal should recurse. Defaults to `true`. - #[ts(optional = nullable)] - pub recursive: Option, - /// Whether missing paths should be ignored. Defaults to `true`. - #[ts(optional = nullable)] - pub force: Option, -} - -/// Successful response for `fs/remove`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsRemoveResponse {} - -/// Copy a file or directory tree on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCopyParams { - /// Absolute source path. - pub source_path: AbsolutePathBuf, - /// Absolute destination path. - pub destination_path: AbsolutePathBuf, - /// Required for directory copies; ignored for file copies. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub recursive: bool, -} - -/// Successful response for `fs/copy`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCopyResponse {} - -/// Start filesystem watch notifications for an absolute path. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWatchParams { - /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. - pub watch_id: String, - /// Absolute file or directory path to watch. - pub path: AbsolutePathBuf, -} - -/// Successful response for `fs/watch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWatchResponse { - /// Canonicalized path associated with the watch. - pub path: AbsolutePathBuf, -} - -/// Stop filesystem watch notifications for a prior `fs/watch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsUnwatchParams { - /// Watch identifier previously provided to `fs/watch`. - pub watch_id: String, -} - -/// Successful response for `fs/unwatch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsUnwatchResponse {} - -/// Filesystem watch notification emitted for `fs/watch` subscribers. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsChangedNotification { - /// Watch identifier previously provided to `fs/watch`. - pub watch_id: String, - /// File or directory paths associated with this event. - pub changed_paths: Vec, -} - -/// PTY size in character cells for `command/exec` PTY sessions. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminalSize { - /// Terminal height in character cells. - pub rows: u16, - /// Terminal width in character cells. - pub cols: u16, -} - -/// Run a standalone command (argv vector) in the server sandbox without -/// creating a thread or turn. -/// -/// The final `command/exec` response is deferred until the process exits and is -/// sent only after all `command/exec/outputDelta` notifications for that -/// connection have been emitted. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecParams { - /// Command argv vector. Empty arrays are rejected. - pub command: Vec, - /// Optional client-supplied, connection-scoped process id. - /// - /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up - /// `command/exec/write`, `command/exec/resize`, and - /// `command/exec/terminate` calls. When omitted, buffered execution gets an - /// internal id that is not exposed to the client. - #[ts(optional = nullable)] - pub process_id: Option, - /// Enable PTY mode. - /// - /// This implies `streamStdin` and `streamStdoutStderr`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub tty: bool, - /// Allow follow-up `command/exec/write` requests to write stdin bytes. - /// - /// Requires a client-supplied `processId`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdin: bool, - /// Stream stdout/stderr via `command/exec/outputDelta` notifications. - /// - /// Streamed bytes are not duplicated into the final response and require a - /// client-supplied `processId`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdout_stderr: bool, - /// Optional per-stream stdout/stderr capture cap in bytes. - /// - /// When omitted, the server default applies. Cannot be combined with - /// `disableOutputCap`. - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub output_bytes_cap: Option, - /// Disable stdout/stderr capture truncation for this request. - /// - /// Cannot be combined with `outputBytesCap`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub disable_output_cap: bool, - /// Disable the timeout entirely for this request. - /// - /// Cannot be combined with `timeoutMs`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub disable_timeout: bool, - /// Optional timeout in milliseconds. - /// - /// When omitted, the server default applies. Cannot be combined with - /// `disableTimeout`. - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub timeout_ms: Option, - /// Optional working directory. Defaults to the server cwd. - #[ts(optional = nullable)] - pub cwd: Option, - /// Optional environment overrides merged into the server-computed - /// environment. - /// - /// Matching names override inherited values. Set a key to `null` to unset - /// an inherited variable. - #[ts(optional = nullable)] - pub env: Option>>, - /// Optional initial PTY size in character cells. Only valid when `tty` is - /// true. - #[ts(optional = nullable)] - pub size: Option, - /// Optional sandbox policy for this command. - /// - /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. Cannot be - /// combined with `permissionProfile`. - #[ts(optional = nullable)] - pub sandbox_policy: Option, - /// Optional full permissions profile for this command. - /// - /// Defaults to the user's configured permissions when omitted. Cannot be - /// combined with `sandboxPolicy`. - #[experimental("command/exec.permissionProfile")] - #[ts(optional = nullable)] - pub permission_profile: Option, -} - -/// Final buffered result for `command/exec`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResponse { - /// Process exit code. - pub exit_code: i32, - /// Buffered stdout capture. - /// - /// Empty when stdout was streamed via `command/exec/outputDelta`. - pub stdout: String, - /// Buffered stderr capture. - /// - /// Empty when stderr was streamed via `command/exec/outputDelta`. - pub stderr: String, -} - -/// Write stdin bytes to a running `command/exec` session, close stdin, or -/// both. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecWriteParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// Optional base64-encoded stdin bytes to write. - #[ts(optional = nullable)] - pub delta_base64: Option, - /// Close stdin after writing `deltaBase64`, if present. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub close_stdin: bool, -} - -/// Empty success response for `command/exec/write`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecWriteResponse {} - -/// Terminate a running `command/exec` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminateParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, -} - -/// Empty success response for `command/exec/terminate`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminateResponse {} - -/// Resize a running PTY-backed `command/exec` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResizeParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// New PTY size in character cells. - pub size: CommandExecTerminalSize, -} - -/// Empty success response for `command/exec/resize`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResizeResponse {} - -/// Stream label for `command/exec/outputDelta` notifications. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecOutputStream { - /// stdout stream. PTY mode multiplexes terminal output here. - Stdout, - /// stderr stream. - Stderr, -} - -/// PTY size in character cells for `process/spawn` PTY sessions. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessTerminalSize { - /// Terminal height in character cells. - pub rows: u16, - /// Terminal width in character cells. - pub cols: u16, -} - -/// Spawn a standalone process (argv vector) without a Codex sandbox on the host -/// where the app server is running. -/// -/// `process/spawn` returns after the process has started and the connection-scoped -/// `processHandle` has been registered. Process output and exit are reported via -/// `process/outputDelta` and `process/exited` notifications. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessSpawnParams { - /// Command argv vector. Empty arrays are rejected. - pub command: Vec, - /// Client-supplied, connection-scoped process handle. - /// - /// Duplicate active handles are rejected on the same connection. The same - /// handle can be reused after the prior process exits. - pub process_handle: String, - /// Absolute working directory for the process. - pub cwd: AbsolutePathBuf, - /// Enable PTY mode. - /// - /// This implies `streamStdin` and `streamStdoutStderr`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub tty: bool, - /// Allow follow-up `process/writeStdin` requests to write stdin bytes. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdin: bool, - /// Stream stdout/stderr via `process/outputDelta` notifications. - /// - /// Streamed bytes are not duplicated into the `process/exited` notification. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdout_stderr: bool, - /// Optional per-stream stdout/stderr capture cap in bytes. - /// - /// When omitted, the server default applies. Set to `null` to disable the - /// cap. - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub output_bytes_cap: Option>, - /// Optional timeout in milliseconds. - /// - /// When omitted, the server default applies. Set to `null` to disable the - /// timeout. - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub timeout_ms: Option>, - /// Optional environment overrides merged into the app-server process - /// environment. - /// - /// Matching names override inherited values. Set a key to `null` to unset - /// an inherited variable. - #[ts(optional = nullable)] - pub env: Option>>, - /// Optional initial PTY size in character cells. Only valid when `tty` is - /// true. - #[ts(optional = nullable)] - pub size: Option, -} - -/// Successful response for `process/spawn`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessSpawnResponse {} - -/// Write stdin bytes to a running `process/spawn` session, close stdin, or -/// both. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessWriteStdinParams { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// Optional base64-encoded stdin bytes to write. - #[ts(optional = nullable)] - pub delta_base64: Option, - /// Close stdin after writing `deltaBase64`, if present. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub close_stdin: bool, -} - -/// Empty success response for `process/writeStdin`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessWriteStdinResponse {} - -/// Terminate a running `process/spawn` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessKillParams { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, -} - -/// Empty success response for `process/kill`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessKillResponse {} - -/// Resize a running PTY-backed `process/spawn` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessResizePtyParams { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// New PTY size in character cells. - pub size: ProcessTerminalSize, -} - -/// Empty success response for `process/resizePty`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessResizePtyResponse {} - -/// Stream label for `process/outputDelta` notifications. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ProcessOutputStream { - /// stdout stream. PTY mode multiplexes terminal output here. - Stdout, - /// stderr stream. - Stderr, -} - -/// Base64-encoded output chunk emitted for a streaming `process/spawn` request. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessOutputDeltaNotification { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// Output stream this chunk belongs to. - pub stream: ProcessOutputStream, - /// Base64-encoded output bytes. - pub delta_base64: String, - /// True on the final streamed chunk for this stream when output was - /// truncated by `outputBytesCap`. - pub cap_reached: bool, -} - -/// Final process exit notification for `process/spawn`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessExitedNotification { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// Process exit code. - pub exit_code: i32, - /// Buffered stdout capture. - /// - /// Empty when stdout was streamed via `process/outputDelta`. - pub stdout: String, - /// Whether stdout reached `outputBytesCap`. - /// - /// In streaming mode, stdout is empty and cap state is also reported on the - /// final stdout `process/outputDelta` notification. - pub stdout_cap_reached: bool, - /// Buffered stderr capture. - /// - /// Empty when stderr was streamed via `process/outputDelta`. - pub stderr: String, - /// Whether stderr reached `outputBytesCap`. - /// - /// In streaming mode, stderr is empty and cap state is also reported on the - /// final stderr `process/outputDelta` notification. - pub stderr_cap_reached: bool, -} - -// === Threads, Turns, and Items === -// Thread APIs -#[derive( - Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartParams { - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/start.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub service_name: Option, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, - #[ts(optional = nullable)] - pub ephemeral: Option, - #[ts(optional = nullable)] - pub session_start_source: Option, - /// Optional sticky environments for this thread. - /// - /// Omitted selects the default environment when environment access is - /// enabled. Empty disables environment access for turns that do not - /// provide a turn override. Non-empty selects the first environment as the - /// current turn environment. - #[experimental("thread/start.environments")] - #[ts(optional = nullable)] - pub environments: Option>, - #[experimental("thread/start.dynamicTools")] - #[ts(optional = nullable)] - pub dynamic_tools: Option>, - /// Test-only experimental field used to validate experimental gating and - /// schema filtering behavior in a stable way. - #[experimental("thread/start.mockExperimentalField")] - #[ts(optional = nullable)] - pub mock_experimental_field: Option, - /// If true, opt into emitting raw Responses API items on the event stream. - /// This is for internal use only (e.g. Codex Cloud). - #[experimental("thread/start.experimentalRawEvents")] - #[serde(default)] - pub experimental_raw_events: bool, - /// If true, persist additional EventMsg variants to the rollout file. - /// However, `thread/read`, `thread/resume`, and `thread/fork` still only - /// return the limited form of thread history for scalability reasons. - #[experimental("thread/start.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodParams { - /// Test-only payload field. - #[ts(optional = nullable)] - pub value: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodResponse { - /// Echoes the input `value`. - pub echoed: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/start.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/start.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are three ways to resume a thread: -/// 1. By thread_id: load the thread from disk by thread_id and resume it. -/// 2. By history: instantiate the thread from memory and resume it. -/// 3. By path: load the thread from disk by path and resume it. -/// -/// The precedence is: history > path > thread_id. -/// If using history or path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadResumeParams { - pub thread_id: String, - - /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. - /// If specified, the thread will be resumed with the provided history - /// instead of loaded from disk. - #[experimental("thread/resume.history")] - #[ts(optional = nullable)] - pub history: Option>, - - /// [UNSTABLE] Specify the rollout path to resume from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/resume.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the resumed thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/resume.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, - /// When true, return only thread metadata and live-resume state without - /// populating `thread.turns`. This is useful when the client plans to call - /// `thread/turns/list` immediately after resuming. - #[experimental("thread/resume.excludeTurns")] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub exclude_turns: bool, - /// If true, persist additional EventMsg variants to the rollout file. - /// However, `thread/read`, `thread/resume`, and `thread/fork` still only - /// return the limited form of thread history for scalability reasons. - #[experimental("thread/resume.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadResumeResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/resume.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/resume.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are two ways to fork a thread: -/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. -/// 2. By path: load the thread from disk by path and fork it into a new thread. -/// -/// If using path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadForkParams { - pub thread_id: String, - - /// [UNSTABLE] Specify the rollout path to fork from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/fork.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the forked thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/fork.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub ephemeral: bool, - /// When true, return only thread metadata and live fork state without - /// populating `thread.turns`. This is useful when the client plans to call - /// `thread/turns/list` immediately after forking. - #[experimental("thread/fork.excludeTurns")] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub exclude_turns: bool, - /// If true, persist additional EventMsg variants to the rollout file. - /// However, `thread/read`, `thread/resume`, and `thread/fork` still only - /// return the limited form of thread history for scalability reasons. - #[experimental("thread/fork.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadForkResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/fork.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/fork.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnsubscribeParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnsubscribeResponse { - pub status: ThreadUnsubscribeStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ThreadUnsubscribeStatus { - NotLoaded, - NotSubscribed, - Unsubscribed, -} - -/// Parameters for `thread/increment_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadIncrementElicitationParams { - /// Thread whose out-of-band elicitation counter should be incremented. - pub thread_id: String, -} - -/// Response for `thread/increment_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadIncrementElicitationResponse { - /// Current out-of-band elicitation count after the increment. - pub count: u64, - /// Whether timeout accounting is paused after applying the increment. - pub paused: bool, -} - -/// Parameters for `thread/decrement_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadDecrementElicitationParams { - /// Thread whose out-of-band elicitation counter should be decremented. - pub thread_id: String, -} - -/// Response for `thread/decrement_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadDecrementElicitationResponse { - /// Current out-of-band elicitation count after the decrement. - pub count: u64, - /// Whether timeout accounting remains paused after applying the decrement. - pub paused: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameParams { - pub thread_id: String, - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameResponse {} - -v2_enum_from_core! { - pub enum ThreadGoalStatus from CoreThreadGoalStatus { - Active, - Paused, - BudgetLimited, - Complete, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoal { - pub thread_id: String, - pub objective: String, - pub status: ThreadGoalStatus, - #[ts(type = "number | null")] - pub token_budget: Option, - #[ts(type = "number")] - pub tokens_used: i64, - #[ts(type = "number")] - pub time_used_seconds: i64, - #[ts(type = "number")] - pub created_at: i64, - #[ts(type = "number")] - pub updated_at: i64, -} - -impl From for ThreadGoal { - fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { - Self { - thread_id: value.thread_id.to_string(), - objective: value.objective, - status: value.status.into(), - token_budget: value.token_budget, - tokens_used: value.tokens_used, - time_used_seconds: value.time_used_seconds, - created_at: value.created_at, - updated_at: value.updated_at, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalSetParams { - pub thread_id: String, - #[ts(optional = nullable)] - pub objective: Option, - #[ts(optional = nullable)] - pub status: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable, type = "number | null")] - pub token_budget: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalSetResponse { - pub goal: ThreadGoal, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalGetParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalGetResponse { - pub goal: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearResponse { - pub cleared: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataUpdateParams { - pub thread_id: String, - /// Patch the stored Git metadata for this thread. - /// Omit a field to leave it unchanged, set it to `null` to clear it, or - /// provide a string to replace the stored value. - #[ts(optional = nullable)] - pub git_info: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataGitInfoUpdateParams { - /// Omit to leave the stored commit unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub sha: Option>, - /// Omit to leave the stored branch unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub branch: Option>, - /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub origin_url: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataUpdateResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(rename_all = "lowercase")] -pub enum ThreadMemoryMode { - Enabled, - Disabled, -} - -impl ThreadMemoryMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Enabled => "enabled", - Self::Disabled => "disabled", - } - } - - pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode { - match self { - Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled, - Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMemoryModeSetParams { - pub thread_id: String, - pub mode: ThreadMemoryMode, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMemoryModeSetResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryResetResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadShellCommandParams { - pub thread_id: String, - /// Shell command string evaluated by the thread's configured shell. - /// Unlike `command/exec`, this intentionally preserves shell syntax - /// such as pipes, redirects, and quoting. This runs unsandboxed with full - /// access rather than inheriting the thread sandbox policy. - pub command: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadShellCommandResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadApproveGuardianDeniedActionParams { - pub thread_id: String, - /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. - pub event: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadApproveGuardianDeniedActionResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackParams { - pub thread_id: String, - /// The number of turns to drop from the end of the thread. Must be >= 1. - /// - /// This only modifies the thread's history and does not revert local file changes - /// that have been made by the agent. Clients are responsible for reverting these changes. - pub num_turns: u32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackResponse { - /// The updated thread after applying the rollback, with `turns` populated. - /// - /// The ThreadItems stored in each Turn are lossy since we explicitly do not - /// persist all agent interactions, such as command executions. This is the same - /// behavior as `thread/resume`. - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional sort key; defaults to created_at. - #[ts(optional = nullable)] - pub sort_key: Option, - /// Optional sort direction; defaults to descending (newest first). - #[ts(optional = nullable)] - pub sort_direction: Option, - /// Optional provider filter; when set, only sessions recorded under these - /// providers are returned. When present but empty, includes all providers. - #[ts(optional = nullable)] - pub model_providers: Option>, - /// Optional source filter; when set, only sessions from these source kinds - /// are returned. When omitted or empty, defaults to interactive sources. - #[ts(optional = nullable)] - pub source_kinds: Option>, - /// Optional archived filter; when set to true, only archived threads are returned. - /// If false or null, only non-archived threads are returned. - #[ts(optional = nullable)] - pub archived: Option, - /// Optional cwd filter or filters; when set, only threads whose session cwd - /// exactly matches one of these paths are returned. - #[ts(optional = nullable, type = "string | Array | null")] - pub cwd: Option, - /// If true, return from the state DB without scanning JSONL rollouts to - /// repair thread metadata. Omitted or false preserves scan-and-repair - /// behavior. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub use_state_db_only: bool, - /// Optional substring filter for the extracted thread title. - #[ts(optional = nullable)] - pub search_term: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[serde(untagged)] -pub enum ThreadListCwdFilter { - One(String), - Many(Vec), -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum ThreadSourceKind { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - VsCode, - Exec, - AppServer, - SubAgent, - SubAgentReview, - SubAgentCompact, - SubAgentThreadSpawn, - SubAgentOther, - Unknown, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum ThreadSortKey { - CreatedAt, - UpdatedAt, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum SortDirection { - Asc, - Desc, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. - pub next_cursor: Option, - /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. - /// This is only populated when the page contains at least one thread. - /// Use it with the opposite `sortDirection`; for timestamp sorts it anchors - /// at the start of the page timestamp so same-second updates are not skipped. - pub backwards_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to no limit. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListResponse { - /// Thread ids for sessions currently loaded in memory. - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ThreadStatus { - NotLoaded, - Idle, - SystemError, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Active { - active_flags: Vec, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ThreadActiveFlag { - WaitingOnApproval, - WaitingOnUserInput, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadParams { - pub thread_id: String, - /// When true, include turns and their items from rollout history. - #[serde(default)] - pub include_turns: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTurnsListParams { - pub thread_id: String, - /// Opaque cursor to pass to the next call to continue after the last turn. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional turn page size. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional turn pagination direction; defaults to descending. - #[ts(optional = nullable)] - pub sort_direction: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTurnsListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last turn. - /// if None, there are no more turns to return. - pub next_cursor: Option, - /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. - /// This is only populated when the page contains at least one turn. - /// Use it with the opposite `sortDirection` to include the anchor turn again - /// and catch updates to that turn. - pub backwards_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListParams { - /// When empty, defaults to the current session working directory. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cwds: Vec, - - /// When true, bypass the skills cache and re-scan skills from disk. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_reload: bool, - - /// Optional per-cwd extra roots to scan as user-scoped skills. - #[serde(default)] - #[ts(optional = nullable)] - pub per_cwd_extra_user_roots: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListExtraRootsForCwd { - pub cwd: PathBuf, - pub extra_user_roots: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListParams { - /// When empty, defaults to the current session working directory. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cwds: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceAddParams { - pub source: String, - #[ts(optional = nullable)] - pub ref_name: Option, - #[ts(optional = nullable)] - pub sparse_paths: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceAddResponse { - pub marketplace_name: String, - pub installed_root: AbsolutePathBuf, - pub already_added: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceRemoveParams { - pub marketplace_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceRemoveResponse { - pub marketplace_name: String, - pub installed_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeParams { - #[ts(optional = nullable)] - pub marketplace_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeResponse { - pub selected_marketplaces: Vec, - pub upgraded_roots: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeErrorInfo { - pub marketplace_name: String, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginListParams { - /// Optional working directories used to discover repo marketplaces. When omitted, - /// only home-scoped marketplaces and the official curated marketplace are considered. - #[ts(optional = nullable)] - pub cwds: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginListResponse { - pub marketplaces: Vec, - #[serde(default)] - pub marketplace_load_errors: Vec, - #[serde(default)] - pub featured_plugin_ids: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceLoadErrorInfo { - pub marketplace_path: AbsolutePathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginReadParams { - #[ts(optional = nullable)] - pub marketplace_path: Option, - #[ts(optional = nullable)] - pub remote_marketplace_name: Option, - pub plugin_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginReadResponse { - pub plugin: PluginDetail, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSkillReadParams { - pub remote_marketplace_name: String, - pub remote_plugin_id: String, - pub skill_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSkillReadResponse { - pub contents: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareSaveParams { - pub plugin_path: AbsolutePathBuf, - #[ts(optional = nullable)] - pub remote_plugin_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareSaveResponse { - pub remote_plugin_id: String, - pub share_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareDeleteParams { - pub remote_plugin_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareDeleteResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListItem { - pub plugin: PluginSummary, - pub share_url: String, - pub local_plugin_path: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum SkillScope { - User, - Repo, - System, - Admin, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillMetadata { - pub name: String, - pub description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. - pub short_description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub interface: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub dependencies: Option, - pub path: AbsolutePathBuf, - pub scope: SkillScope, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillInterface { - #[ts(optional)] - pub display_name: Option, - #[ts(optional)] - pub short_description: Option, - #[ts(optional)] - pub icon_small: Option, - #[ts(optional)] - pub icon_large: Option, - #[ts(optional)] - pub brand_color: Option, - #[ts(optional)] - pub default_prompt: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillDependencies { - pub tools: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillToolDependency { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub r#type: String, - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub transport: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub command: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub url: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListEntry { - pub cwd: PathBuf, - pub skills: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListEntry { - pub cwd: PathBuf, - pub hooks: Vec, - pub warnings: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookMetadata { - pub key: String, - pub event_name: HookEventName, - pub handler_type: HookHandlerType, - pub matcher: Option, - pub command: Option, - pub timeout_sec: u64, - pub status_message: Option, - pub source_path: AbsolutePathBuf, - pub source: HookSource, - pub plugin_id: Option, - pub display_order: i64, - pub enabled: bool, - pub is_managed: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginMarketplaceEntry { - pub name: String, - /// Local marketplace file path when the marketplace is backed by a local file. - /// Remote-only catalog marketplaces do not have a local path. - pub path: Option, - pub interface: Option, - pub plugins: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceInterface { - pub display_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginInstallPolicy { - #[serde(rename = "NOT_AVAILABLE")] - #[ts(rename = "NOT_AVAILABLE")] - NotAvailable, - #[serde(rename = "AVAILABLE")] - #[ts(rename = "AVAILABLE")] - Available, - #[serde(rename = "INSTALLED_BY_DEFAULT")] - #[ts(rename = "INSTALLED_BY_DEFAULT")] - InstalledByDefault, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginAuthPolicy { - #[serde(rename = "ON_INSTALL")] - #[ts(rename = "ON_INSTALL")] - OnInstall, - #[serde(rename = "ON_USE")] - #[ts(rename = "ON_USE")] - OnUse, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginAvailability { - /// Plugin-service currently sends `"ENABLED"` for available remote plugins. - /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps - /// decoding compatible with that upstream response. - #[serde(rename = "AVAILABLE", alias = "ENABLED")] - #[ts(rename = "AVAILABLE")] - #[default] - Available, - #[serde(rename = "DISABLED_BY_ADMIN")] - #[ts(rename = "DISABLED_BY_ADMIN")] - DisabledByAdmin, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSummary { - pub id: String, - pub name: String, - pub source: PluginSource, - pub installed: bool, - pub enabled: bool, - pub install_policy: PluginInstallPolicy, - pub auth_policy: PluginAuthPolicy, - /// Availability state for installing and using the plugin. - #[serde(default)] - pub availability: PluginAvailability, - pub interface: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginDetail { - pub marketplace_name: String, - pub marketplace_path: Option, - pub summary: PluginSummary, - pub description: Option, - pub skills: Vec, - pub apps: Vec, - pub mcp_servers: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillSummary { - pub name: String, - pub description: String, - pub short_description: Option, - pub interface: Option, - pub path: Option, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInterface { - pub display_name: Option, - pub short_description: Option, - pub long_description: Option, - pub developer_name: Option, - pub category: Option, - pub capabilities: Vec, - pub website_url: Option, - pub privacy_policy_url: Option, - pub terms_of_service_url: Option, - /// Starter prompts for the plugin. Capped at 3 entries with a maximum of - /// 128 characters per entry. - pub default_prompt: Option>, - pub brand_color: Option, - /// Local composer icon path, resolved from the installed plugin package. - pub composer_icon: Option, - /// Remote composer icon URL from the plugin catalog. - pub composer_icon_url: Option, - /// Local logo path, resolved from the installed plugin package. - pub logo: Option, - /// Remote logo URL from the plugin catalog. - pub logo_url: Option, - /// Local screenshot paths, resolved from the installed plugin package. - pub screenshots: Vec, - /// Remote screenshot URLs from the plugin catalog. - pub screenshot_urls: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PluginSource { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Local { path: AbsolutePathBuf }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Git { - url: String, - path: Option, - ref_name: Option, - sha: Option, - }, - /// The plugin is available in the remote catalog. Download metadata is - /// kept server-side and is not exposed through the app-server API. - Remote, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteParams { - /// Path-based selector. - #[ts(optional = nullable)] - pub path: Option, - /// Name-based selector. - #[ts(optional = nullable)] - pub name: Option, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteResponse { - pub effective_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInstallParams { - #[ts(optional = nullable)] - pub marketplace_path: Option, - #[ts(optional = nullable)] - pub remote_marketplace_name: Option, - pub plugin_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInstallResponse { - pub auth_policy: PluginAuthPolicy, - pub apps_needing_auth: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginUninstallParams { - pub plugin_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginUninstallResponse {} - -impl From for SkillMetadata { - fn from(value: CoreSkillMetadata) -> Self { - Self { - name: value.name, - description: value.description, - short_description: value.short_description, - interface: value.interface.map(SkillInterface::from), - dependencies: value.dependencies.map(SkillDependencies::from), - path: value.path, - scope: value.scope.into(), - enabled: true, - } - } -} - -impl From for SkillInterface { - fn from(value: CoreSkillInterface) -> Self { - Self { - display_name: value.display_name, - short_description: value.short_description, - brand_color: value.brand_color, - default_prompt: value.default_prompt, - icon_small: value.icon_small, - icon_large: value.icon_large, - } - } -} - -impl From for SkillDependencies { - fn from(value: CoreSkillDependencies) -> Self { - Self { - tools: value - .tools - .into_iter() - .map(SkillToolDependency::from) - .collect(), - } - } -} - -impl From for SkillToolDependency { - fn from(value: CoreSkillToolDependency) -> Self { - Self { - r#type: value.r#type, - value: value.value, - description: value.description, - transport: value.transport, - command: value.command, - url: value.url, - } - } -} - -impl From for SkillScope { - fn from(value: CoreSkillScope) -> Self { - match value { - CoreSkillScope::User => Self::User, - CoreSkillScope::Repo => Self::Repo, - CoreSkillScope::System => Self::System, - CoreSkillScope::Admin => Self::Admin, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Thread { - pub id: String, - /// Source thread id when this thread was created by forking another thread. - pub forked_from_id: Option, - /// Usually the first user message in the thread, if available. - pub preview: String, - /// Whether the thread is ephemeral and should not be materialized on disk. - pub ephemeral: bool, - /// Model provider used for this thread (for example, 'openai'). - pub model_provider: String, - /// Unix timestamp (in seconds) when the thread was created. - #[ts(type = "number")] - pub created_at: i64, - /// Unix timestamp (in seconds) when the thread was last updated. - #[ts(type = "number")] - pub updated_at: i64, - /// Current runtime status for the thread. - pub status: ThreadStatus, - /// [UNSTABLE] Path to the thread on disk. - pub path: Option, - /// Working directory captured for the thread. - pub cwd: AbsolutePathBuf, - /// Version of the CLI that created the thread. - pub cli_version: String, - /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). - pub source: SessionSource, - /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. - pub agent_nickname: Option, - /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. - pub agent_role: Option, - /// Optional Git metadata captured when the thread was created. - pub git_info: Option, - /// Optional user-facing thread title. - pub name: Option, - /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` - /// (when `includeTurns` is true) responses. - /// For all other responses and notifications returning a Thread, - /// the turns field will be an empty list. - pub turns: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountUpdatedNotification { - pub auth_mode: Option, - pub plan_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsageUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub token_usage: ThreadTokenUsage, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsage { - pub total: TokenUsageBreakdown, - pub last: TokenUsageBreakdown, - // TODO(aibrahim): make this not optional - #[ts(type = "number | null")] - pub model_context_window: Option, -} - -impl From for ThreadTokenUsage { - fn from(value: CoreTokenUsageInfo) -> Self { - Self { - total: value.total_token_usage.into(), - last: value.last_token_usage.into(), - model_context_window: value.model_context_window, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TokenUsageBreakdown { - #[ts(type = "number")] - pub total_tokens: i64, - #[ts(type = "number")] - pub input_tokens: i64, - #[ts(type = "number")] - pub cached_input_tokens: i64, - #[ts(type = "number")] - pub output_tokens: i64, - #[ts(type = "number")] - pub reasoning_output_tokens: i64, -} - -impl From for TokenUsageBreakdown { - fn from(value: CoreTokenUsage) -> Self { - Self { - total_tokens: value.total_tokens, - input_tokens: value.input_tokens, - cached_input_tokens: value.cached_input_tokens, - output_tokens: value.output_tokens, - reasoning_output_tokens: value.reasoning_output_tokens, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Turn { - pub id: String, - /// Only populated on a `thread/resume` or `thread/fork` response. - /// For all other responses and notifications returning a Turn, - /// the items field will be an empty list. - pub items: Vec, - pub status: TurnStatus, - /// Only populated when the Turn's status is failed. - pub error: Option, - /// Unix timestamp (in seconds) when the turn started. - #[ts(type = "number | null")] - pub started_at: Option, - /// Unix timestamp (in seconds) when the turn completed. - #[ts(type = "number | null")] - pub completed_at: Option, - /// Duration between turn start and completion in milliseconds, if known. - #[ts(type = "number | null")] - pub duration_ms: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryCitation { - pub entries: Vec, - pub thread_ids: Vec, -} - -impl From for MemoryCitation { - fn from(value: CoreMemoryCitation) -> Self { - Self { - entries: value.entries.into_iter().map(Into::into).collect(), - thread_ids: value.rollout_ids, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryCitationEntry { - pub path: String, - pub line_start: u32, - pub line_end: u32, - pub note: String, -} - -impl From for MemoryCitationEntry { - fn from(value: CoreMemoryCitationEntry) -> Self { - Self { - path: value.path, - line_start: value.line_start, - line_end: value.line_end, - note: value.note, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -#[error("{message}")] -pub struct TurnError { - pub message: String, - pub codex_error_info: Option, - #[serde(default)] - pub additional_details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ErrorNotification { - pub error: TurnError, - // Set to true if the error is transient and the app-server process will automatically retry. - // If true, this will not interrupt a turn. - pub will_retry: bool, - pub thread_id: String, - pub turn_id: String, -} - -/// EXPERIMENTAL - thread realtime audio chunk. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAudioChunk { - pub data: String, - pub sample_rate: u32, - pub num_channels: u16, - pub samples_per_channel: Option, - pub item_id: Option, -} - -impl From for ThreadRealtimeAudioChunk { - fn from(value: CoreRealtimeAudioFrame) -> Self { - let CoreRealtimeAudioFrame { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } = value; - Self { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } - } -} - -impl From for CoreRealtimeAudioFrame { - fn from(value: ThreadRealtimeAudioChunk) -> Self { - let ThreadRealtimeAudioChunk { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } = value; - Self { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } - } -} - -/// EXPERIMENTAL - start a thread-scoped realtime session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartParams { - pub thread_id: String, - /// Selects text or audio output for the realtime session. Transport and voice stay - /// independent so clients can choose how they connect separately from what the model emits. - pub output_modality: RealtimeOutputModality, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub prompt: Option>, - #[ts(optional = nullable)] - pub realtime_session_id: Option, - #[ts(optional = nullable)] - pub transport: Option, - #[ts(optional = nullable)] - pub voice: Option, -} - -/// EXPERIMENTAL - transport used by thread realtime. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/", tag = "type")] -pub enum ThreadRealtimeStartTransport { - Websocket, - Webrtc { - /// SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the - /// realtime events data channel. - sdp: String, - }, -} - -/// EXPERIMENTAL - response for starting thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartResponse {} - -/// EXPERIMENTAL - append audio input to thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendAudioParams { - pub thread_id: String, - pub audio: ThreadRealtimeAudioChunk, -} - -/// EXPERIMENTAL - response for appending realtime audio input. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendAudioResponse {} - -/// EXPERIMENTAL - append text input to thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendTextParams { - pub thread_id: String, - pub text: String, -} - -/// EXPERIMENTAL - response for appending realtime text input. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendTextResponse {} - -/// EXPERIMENTAL - stop thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStopParams { - pub thread_id: String, -} - -/// EXPERIMENTAL - response for stopping thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStopResponse {} - -/// EXPERIMENTAL - list voices supported by thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeListVoicesParams {} - -/// EXPERIMENTAL - response for listing supported realtime voices. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeListVoicesResponse { - pub voices: RealtimeVoicesList, -} - -/// EXPERIMENTAL - emitted when thread realtime startup is accepted. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartedNotification { - pub thread_id: String, - pub realtime_session_id: Option, - pub version: RealtimeConversationVersion, -} - -/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeItemAddedNotification { - pub thread_id: String, - pub item: JsonValue, -} - -/// EXPERIMENTAL - flat transcript delta emitted whenever realtime -/// transcript text changes. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeTranscriptDeltaNotification { - pub thread_id: String, - pub role: String, - /// Live transcript delta from the realtime event. - pub delta: String, -} - -/// EXPERIMENTAL - final transcript text emitted when realtime completes -/// a transcript part. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeTranscriptDoneNotification { - pub thread_id: String, - pub role: String, - /// Final complete text for the transcript part. - pub text: String, -} - -/// EXPERIMENTAL - streamed output audio emitted by thread realtime. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeOutputAudioDeltaNotification { - pub thread_id: String, - pub audio: ThreadRealtimeAudioChunk, -} - -/// EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeSdpNotification { - pub thread_id: String, - pub sdp: String, -} - -/// EXPERIMENTAL - emitted when thread realtime encounters an error. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeErrorNotification { - pub thread_id: String, - pub message: String, -} - -/// EXPERIMENTAL - emitted when thread realtime transport closes. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeClosedNotification { - pub thread_id: String, - pub reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnStatus { - Completed, - Interrupted, - Failed, - InProgress, -} - -// Turn APIs -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnEnvironmentParams { - pub environment_id: String, - pub cwd: AbsolutePathBuf, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartParams { - pub thread_id: String, - pub input: Vec, - /// Optional turn-scoped Responses API client metadata. - #[experimental("turn/start.responsesapiClientMetadata")] - #[ts(optional = nullable)] - pub responsesapi_client_metadata: Option>, - /// Optional turn-scoped environments. - /// - /// Omitted uses the thread sticky environments. Empty disables - /// environment access for this turn. Non-empty selects the first - /// environment as the current turn environment for this turn. - #[experimental("turn/start.environments")] - #[ts(optional = nullable)] - pub environments: Option>, - /// Override the working directory for this turn and subsequent turns. - #[ts(optional = nullable)] - pub cwd: Option, - /// Override the approval policy for this turn and subsequent turns. - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this turn and - /// subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - /// Override the sandbox policy for this turn and subsequent turns. - #[ts(optional = nullable)] - pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. - #[experimental("turn/start.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - /// Override the model for this turn and subsequent turns. - #[ts(optional = nullable)] - pub model: Option, - /// Override the service tier for this turn and subsequent turns. - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - /// Override the reasoning effort for this turn and subsequent turns. - #[ts(optional = nullable)] - pub effort: Option, - /// Override the reasoning summary for this turn and subsequent turns. - #[ts(optional = nullable)] - pub summary: Option, - /// Override the personality for this turn and subsequent turns. - #[ts(optional = nullable)] - pub personality: Option, - /// Optional JSON Schema used to constrain the final assistant message for - /// this turn. - #[ts(optional = nullable)] - pub output_schema: Option, - - /// EXPERIMENTAL - Set a pre-set collaboration mode. - /// Takes precedence over model, reasoning_effort, and developer instructions if set. - /// - /// For `collaboration_mode.settings.developer_instructions`, `null` means - /// "use the built-in instructions for the selected mode". - #[experimental("turn/start.collaborationMode")] - #[ts(optional = nullable)] - pub collaboration_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartParams { - pub thread_id: String, - pub target: ReviewTarget, - - /// Where to run the review: inline (default) on the current thread or - /// detached on a new thread (returned in `reviewThreadId`). - #[serde(default)] - #[ts(optional = nullable)] - pub delivery: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartResponse { - pub turn: Turn, - /// Identifies the thread where the review runs. - /// - /// For inline reviews, this is the original thread id. - /// For detached reviews, this is the id of the new review thread. - pub review_thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", export_to = "v2/")] -pub enum ReviewTarget { - /// Review the working tree: staged, unstaged, and untracked files. - UncommittedChanges, - - /// Review changes between the current branch and the given base branch. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - BaseBranch { branch: String }, - - /// Review the changes introduced by a specific commit. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Commit { - sha: String, - /// Optional human-readable label (e.g., commit subject) for UIs. - title: Option, - }, - - /// Arbitrary instructions, equivalent to the old free-form prompt. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Custom { instructions: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartResponse { - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadInjectItemsParams { - pub thread_id: String, - /// Raw Responses API items to append to the thread's model-visible history. - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadInjectItemsResponse {} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerParams { - pub thread_id: String, - pub input: Vec, - /// Optional turn-scoped Responses API client metadata. - #[experimental("turn/steer.responsesapiClientMetadata")] - #[ts(optional = nullable)] - pub responsesapi_client_metadata: Option>, - /// Required active turn id precondition. The request fails when it does not - /// match the currently active turn. - pub expected_turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerResponse { - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptParams { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptResponse {} - -// User input types -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ByteRange { - pub start: usize, - pub end: usize, -} - -impl From for ByteRange { - fn from(value: CoreByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -impl From for CoreByteRange { - fn from(value: ByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextElement { - /// Byte range in the parent `text` buffer that this element occupies. - pub byte_range: ByteRange, - /// Optional human-readable placeholder for the element, displayed in the UI. - placeholder: Option, -} - -impl TextElement { - pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { - Self { - byte_range, - placeholder, - } - } - - pub fn set_placeholder(&mut self, placeholder: Option) { - self.placeholder = placeholder; - } - - pub fn placeholder(&self) -> Option<&str> { - self.placeholder.as_deref() - } -} - -impl From for TextElement { - fn from(value: CoreTextElement) -> Self { - Self::new( - value.byte_range.into(), - value._placeholder_for_conversion_only().map(str::to_string), - ) - } -} - -impl From for CoreTextElement { - fn from(value: TextElement) -> Self { - Self::new(value.byte_range.into(), value.placeholder) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum UserInput { - Text { - text: String, - /// UI-defined spans within `text` used to render or persist special elements. - #[serde(default)] - text_elements: Vec, - }, - Image { - url: String, - }, - LocalImage { - path: PathBuf, - }, - Skill { - name: String, - path: PathBuf, - }, - Mention { - name: String, - path: String, - #[serde(default)] - computer_use_native_app_bundle_id: Option, - }, -} - -impl UserInput { - pub fn into_core(self) -> CoreUserInput { - match self { - UserInput::Text { - text, - text_elements, - } => CoreUserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - UserInput::Image { url } => CoreUserInput::Image { image_url: url }, - UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, - UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, - UserInput::Mention { - name, - path, - computer_use_native_app_bundle_id, - } => CoreUserInput::Mention { - name, - path, - computer_use_native_app_bundle_id, - }, - } - } -} - -impl From for UserInput { - fn from(value: CoreUserInput) -> Self { - match value { - CoreUserInput::Text { - text, - text_elements, - } => UserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, - CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, - CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, - CoreUserInput::Mention { - name, - path, - computer_use_native_app_bundle_id, - } => UserInput::Mention { - name, - path, - computer_use_native_app_bundle_id, - }, - _ => unreachable!("unsupported user input variant"), - } - } -} - -impl UserInput { - pub fn text_char_count(&self) -> usize { - match self { - UserInput::Text { text, .. } => text.chars().count(), - UserInput::Image { .. } - | UserInput::LocalImage { .. } - | UserInput::Skill { .. } - | UserInput::Mention { .. } => 0, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ThreadItem { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - UserMessage { id: String, content: Vec }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - HookPrompt { - id: String, - fragments: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AgentMessage { - id: String, - text: String, - #[serde(default)] - phase: Option, - #[serde(default)] - memory_citation: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - /// EXPERIMENTAL - proposed plan item content. The completed plan item is - /// authoritative and may not match the concatenation of `PlanDelta` text. - Plan { id: String, text: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Reasoning { - id: String, - #[serde(default)] - summary: Vec, - #[serde(default)] - content: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CommandExecution { - id: String, - /// The command to be executed. - command: String, - /// The command's working directory. - cwd: AbsolutePathBuf, - /// Identifier for the underlying PTY process (when available). - process_id: Option, - #[serde(default)] - source: CommandExecutionSource, - status: CommandExecutionStatus, - /// 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. - command_actions: Vec, - /// The command's output, aggregated from stdout and stderr. - aggregated_output: Option, - /// The command's exit code. - exit_code: Option, - /// The duration of the command execution in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - FileChange { - id: String, - changes: Vec, - status: PatchApplyStatus, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - McpToolCall { - id: String, - server: String, - tool: String, - status: McpToolCallStatus, - arguments: JsonValue, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - mcp_app_resource_uri: Option, - result: Option>, - error: Option, - /// The duration of the MCP tool call in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - DynamicToolCall { - id: String, - namespace: Option, - tool: String, - arguments: JsonValue, - status: DynamicToolCallStatus, - content_items: Option>, - success: Option, - /// The duration of the dynamic tool call in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CollabAgentToolCall { - /// Unique identifier for this collab tool call. - id: String, - /// Name of the collab tool that was invoked. - tool: CollabAgentTool, - /// Current status of the collab tool call. - status: CollabAgentToolCallStatus, - /// Thread ID of the agent issuing the collab request. - sender_thread_id: String, - /// Thread ID of the receiving agent, when applicable. In case of spawn operation, - /// this corresponds to the newly spawned agent. - receiver_thread_ids: Vec, - /// Prompt text sent as part of the collab tool call, when available. - prompt: Option, - /// Model requested for the spawned agent, when applicable. - model: Option, - /// Reasoning effort requested for the spawned agent, when applicable. - reasoning_effort: Option, - /// Last known status of the target agents, when available. - agents_states: HashMap, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WebSearch { - id: String, - query: String, - action: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageView { id: String, path: AbsolutePathBuf }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageGeneration { - id: String, - status: String, - revised_prompt: Option, - result: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - saved_path: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - EnteredReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExitedReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ContextCompaction { id: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub struct HookPromptFragment { - pub text: String, - pub hook_run_id: String, -} - -impl ThreadItem { - pub fn id(&self) -> &str { - match self { - ThreadItem::UserMessage { id, .. } - | ThreadItem::HookPrompt { id, .. } - | ThreadItem::AgentMessage { id, .. } - | ThreadItem::Plan { id, .. } - | ThreadItem::Reasoning { id, .. } - | ThreadItem::CommandExecution { id, .. } - | ThreadItem::FileChange { id, .. } - | ThreadItem::McpToolCall { id, .. } - | ThreadItem::DynamicToolCall { id, .. } - | ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::WebSearch { id, .. } - | ThreadItem::ImageView { id, .. } - | ThreadItem::ImageGeneration { id, .. } - | ThreadItem::EnteredReviewMode { id, .. } - | ThreadItem::ExitedReviewMode { id, .. } - | ThreadItem::ContextCompaction { id, .. } => id, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Lifecycle state for an approval auto-review. -pub enum GuardianApprovalReviewStatus { - InProgress, - Approved, - Denied, - TimedOut, - Aborted, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Source that produced a terminal approval auto-review decision. -pub enum AutoReviewDecisionSource { - Agent, -} - -impl From for AutoReviewDecisionSource { - fn from(value: CoreGuardianAssessmentDecisionSource) -> Self { - match value { - CoreGuardianAssessmentDecisionSource::Agent => Self::Agent, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Risk level assigned by approval auto-review. -pub enum GuardianRiskLevel { - Low, - Medium, - High, - Critical, -} - -impl From for GuardianRiskLevel { - fn from(value: CoreGuardianRiskLevel) -> Self { - match value { - CoreGuardianRiskLevel::Low => Self::Low, - CoreGuardianRiskLevel::Medium => Self::Medium, - CoreGuardianRiskLevel::High => Self::High, - CoreGuardianRiskLevel::Critical => Self::Critical, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Authorization level assigned by approval auto-review. -pub enum GuardianUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -impl From for GuardianUserAuthorization { - fn from(value: CoreGuardianUserAuthorization) -> Self { - match value { - CoreGuardianUserAuthorization::Unknown => Self::Unknown, - CoreGuardianUserAuthorization::Low => Self::Low, - CoreGuardianUserAuthorization::Medium => Self::Medium, - CoreGuardianUserAuthorization::High => Self::High, - } - } -} - -/// [UNSTABLE] Temporary approval auto-review payload used by -/// `item/autoApprovalReview/*` notifications. This shape is expected to change -/// soon. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianApprovalReview { - pub status: GuardianApprovalReviewStatus, - pub risk_level: Option, - pub user_authorization: Option, - pub rationale: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum GuardianCommandSource { - Shell, - UnifiedExec, -} - -impl From for GuardianCommandSource { - fn from(value: CoreGuardianCommandSource) -> Self { - match value { - CoreGuardianCommandSource::Shell => Self::Shell, - CoreGuardianCommandSource::UnifiedExec => Self::UnifiedExec, - } - } -} - -impl From for CoreGuardianCommandSource { - fn from(value: GuardianCommandSource) -> Self { - match value { - GuardianCommandSource::Shell => Self::Shell, - GuardianCommandSource::UnifiedExec => Self::UnifiedExec, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianCommandReviewAction { - pub source: GuardianCommandSource, - pub command: String, - pub cwd: AbsolutePathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianExecveReviewAction { - pub source: GuardianCommandSource, - pub program: String, - pub argv: Vec, - pub cwd: AbsolutePathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianApplyPatchReviewAction { - pub cwd: AbsolutePathBuf, - pub files: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianNetworkAccessReviewAction { - pub target: String, - pub host: String, - pub protocol: NetworkApprovalProtocol, - pub port: u16, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianMcpToolCallReviewAction { - pub server: String, - pub tool_name: String, - pub connector_id: Option, - pub connector_name: Option, - pub tool_title: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianRequestPermissionsReviewAction { - pub reason: Option, - pub permissions: RequestPermissionProfile, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum GuardianApprovalReviewAction { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Command { - source: GuardianCommandSource, - command: String, - cwd: AbsolutePathBuf, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Execve { - source: GuardianCommandSource, - program: String, - argv: Vec, - cwd: AbsolutePathBuf, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ApplyPatch { - cwd: AbsolutePathBuf, - files: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - NetworkAccess { - target: String, - host: String, - protocol: NetworkApprovalProtocol, - port: u16, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - McpToolCall { - server: String, - tool_name: String, - connector_id: Option, - connector_name: Option, - tool_title: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RequestPermissions { - reason: Option, - permissions: RequestPermissionProfile, - }, -} - -impl From for GuardianApprovalReviewAction { - fn from(value: CoreGuardianAssessmentAction) -> Self { - match value { - CoreGuardianAssessmentAction::Command { - source, - command, - cwd, - } => Self::Command { - source: source.into(), - command, - cwd, - }, - CoreGuardianAssessmentAction::Execve { - source, - program, - argv, - cwd, - } => Self::Execve { - source: source.into(), - program, - argv, - cwd, - }, - CoreGuardianAssessmentAction::ApplyPatch { cwd, files } => { - Self::ApplyPatch { cwd, files } - } - CoreGuardianAssessmentAction::NetworkAccess { - target, - host, - protocol, - port, - } => Self::NetworkAccess { - target, - host, - protocol: protocol.into(), - port, - }, - CoreGuardianAssessmentAction::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - } => Self::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - }, - CoreGuardianAssessmentAction::RequestPermissions { - reason, - permissions, - } => Self::RequestPermissions { - reason, - permissions: permissions.into(), - }, - } - } -} - -impl From for CoreGuardianAssessmentAction { - fn from(value: GuardianApprovalReviewAction) -> Self { - match value { - GuardianApprovalReviewAction::Command { - source, - command, - cwd, - } => Self::Command { - source: source.into(), - command, - cwd, - }, - GuardianApprovalReviewAction::Execve { - source, - program, - argv, - cwd, - } => Self::Execve { - source: source.into(), - program, - argv, - cwd, - }, - GuardianApprovalReviewAction::ApplyPatch { cwd, files } => { - Self::ApplyPatch { cwd, files } - } - GuardianApprovalReviewAction::NetworkAccess { - target, - host, - protocol, - port, - } => Self::NetworkAccess { - target, - host, - protocol: protocol.to_core(), - port, - }, - GuardianApprovalReviewAction::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - } => Self::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - }, - GuardianApprovalReviewAction::RequestPermissions { - reason, - permissions, - } => Self::RequestPermissions { - reason, - permissions: permissions.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WebSearchAction { - Search { - query: Option, - queries: Option>, - }, - OpenPage { - url: Option, - }, - FindInPage { - url: Option, - pattern: Option, - }, - #[serde(other)] - Other, -} - -impl From for WebSearchAction { - fn from(value: codex_protocol::models::WebSearchAction) -> Self { - match value { - codex_protocol::models::WebSearchAction::Search { query, queries } => { - WebSearchAction::Search { query, queries } - } - codex_protocol::models::WebSearchAction::OpenPage { url } => { - WebSearchAction::OpenPage { url } - } - codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { - WebSearchAction::FindInPage { url, pattern } - } - codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, - } - } -} - -impl From for ThreadItem { - fn from(value: CoreTurnItem) -> Self { - match value { - CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { - id: user.id, - content: user.content.into_iter().map(UserInput::from).collect(), - }, - CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { - id: hook_prompt.id, - fragments: hook_prompt - .fragments - .into_iter() - .map(HookPromptFragment::from) - .collect(), - }, - CoreTurnItem::AgentMessage(agent) => { - let text = agent - .content - .into_iter() - .map(|entry| match entry { - CoreAgentMessageContent::Text { text } => text, - }) - .collect::(); - ThreadItem::AgentMessage { - id: agent.id, - text, - phase: agent.phase, - memory_citation: agent.memory_citation.map(Into::into), - } - } - CoreTurnItem::Plan(plan) => ThreadItem::Plan { - id: plan.id, - text: plan.text, - }, - CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { - id: reasoning.id, - summary: reasoning.summary_text, - content: reasoning.raw_content, - }, - CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { - id: search.id, - query: search.query, - action: Some(WebSearchAction::from(search.action)), - }, - CoreTurnItem::ImageView(image) => ThreadItem::ImageView { - id: image.id, - path: image.path, - }, - CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { - id: image.id, - status: image.status, - revised_prompt: image.revised_prompt, - result: image.result, - saved_path: image.saved_path, - }, - CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { - id: file_change.id, - changes: convert_patch_changes(&file_change.changes), - status: file_change - .status - .as_ref() - .map(PatchApplyStatus::from) - .unwrap_or(PatchApplyStatus::InProgress), - }, - CoreTurnItem::McpToolCall(mcp) => { - let duration_ms = mcp - .duration - .and_then(|duration| i64::try_from(duration.as_millis()).ok()); - - ThreadItem::McpToolCall { - id: mcp.id, - server: mcp.server, - tool: mcp.tool, - status: McpToolCallStatus::from(mcp.status), - arguments: mcp.arguments, - mcp_app_resource_uri: mcp.mcp_app_resource_uri, - result: mcp.result.map(McpToolCallResult::from).map(Box::new), - error: mcp.error.map(McpToolCallError::from), - duration_ms, - } - } - CoreTurnItem::ContextCompaction(compaction) => { - ThreadItem::ContextCompaction { id: compaction.id } - } - } - } -} - -impl From for HookPromptFragment { - fn from(value: codex_protocol::items::HookPromptFragment) -> Self { - Self { - text: value.text, - hook_run_id: value.hook_run_id, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionStatus { - InProgress, - Completed, - Failed, - Declined, -} - -impl From for CommandExecutionStatus { - fn from(value: CoreExecCommandStatus) -> Self { - Self::from(&value) - } -} - -impl From<&CoreExecCommandStatus> for CommandExecutionStatus { - fn from(value: &CoreExecCommandStatus) -> Self { - match value { - CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed, - CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed, - CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined, - } - } -} - -v2_enum_from_core! { - #[derive(Default)] - pub enum CommandExecutionSource from CoreExecCommandSource { - #[default] - Agent, - UserShell, - UnifiedExecStartup, - UnifiedExecInteraction, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentTool { - SpawnAgent, - SendInput, - ResumeAgent, - Wait, - CloseAgent, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileUpdateChange { - pub path: String, - pub kind: PatchChangeKind, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PatchChangeKind { - Add, - Delete, - Update { move_path: Option }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum PatchApplyStatus { - InProgress, - Completed, - Failed, - Declined, -} - -impl From for PatchApplyStatus { - fn from(value: CorePatchApplyStatus) -> Self { - Self::from(&value) - } -} - -impl From<&CorePatchApplyStatus> for PatchApplyStatus { - fn from(value: &CorePatchApplyStatus) -> Self { - match value { - CorePatchApplyStatus::Completed => PatchApplyStatus::Completed, - CorePatchApplyStatus::Failed => PatchApplyStatus::Failed, - CorePatchApplyStatus::Declined => PatchApplyStatus::Declined, - } - } -} - -impl From for McpToolCallStatus { - fn from(value: CoreMcpToolCallStatus) -> Self { - match value { - CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress, - CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed, - CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum DynamicToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentStatus { - PendingInit, - Running, - Interrupted, - Completed, - Errored, - Shutdown, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollabAgentState { - pub status: CollabAgentStatus, - pub message: Option, -} - -impl From for CollabAgentState { - fn from(value: CoreAgentStatus) -> Self { - match value { - CoreAgentStatus::PendingInit => Self { - status: CollabAgentStatus::PendingInit, - message: None, - }, - CoreAgentStatus::Running => Self { - status: CollabAgentStatus::Running, - message: None, - }, - CoreAgentStatus::Interrupted => Self { - status: CollabAgentStatus::Interrupted, - message: None, - }, - CoreAgentStatus::Completed(message) => Self { - status: CollabAgentStatus::Completed, - message, - }, - CoreAgentStatus::Errored(message) => Self { - status: CollabAgentStatus::Errored, - message: Some(message), - }, - CoreAgentStatus::Shutdown => Self { - status: CollabAgentStatus::Shutdown, - message: None, - }, - CoreAgentStatus::NotFound => Self { - status: CollabAgentStatus::NotFound, - message: None, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallResult { - // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust - // representation of MCP content blocks. We intentionally use `serde_json::Value` here because - // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types - // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust - // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. - pub content: Vec, - pub structured_content: Option, - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - pub meta: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallError { - pub message: String, -} - -// === Server Notifications === -// Thread/Turn lifecycle notifications and item progress events -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartedNotification { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStatusChangedNotification { - pub thread_id: String, - pub status: ThreadStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchivedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchivedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadClosedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// Notification emitted when watched local skill files change. -/// -/// Treat this as an invalidation signal and re-run `skills/list` with the -/// client's current parameters when refreshed skill metadata is needed. -pub struct SkillsChangedNotification {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadNameUpdatedNotification { - pub thread_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub thread_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalUpdatedNotification { - pub thread_id: String, - pub turn_id: Option, - pub goal: ThreadGoal, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookStartedNotification { - pub thread_id: String, - pub turn_id: Option, - pub run: HookRunSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Usage { - pub input_tokens: i32, - pub cached_input_tokens: i32, - pub output_tokens: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnCompletedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookCompletedNotification { - pub thread_id: String, - pub turn_id: Option, - pub run: HookRunSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// Notification that the turn-level unified diff has changed. -/// Contains the latest aggregated diff across all file changes in the turn. -pub struct TurnDiffUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub explanation: Option, - pub plan: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanStep { - pub step: String, - pub status: TurnPlanStepStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnPlanStepStatus { - Pending, - InProgress, - Completed, -} - -impl From for TurnPlanStep { - fn from(value: CorePlanItemArg) -> Self { - Self { - step: value.step, - status: value.status.into(), - } - } -} - -impl From for TurnPlanStepStatus { - fn from(value: CorePlanStepStatus) -> Self { - match value { - CorePlanStepStatus::Pending => Self::Pending, - CorePlanStepStatus::InProgress => Self::InProgress, - CorePlanStepStatus::Completed => Self::Completed, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemStartedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, - /// Unix timestamp (in milliseconds) when this item lifecycle started. - #[ts(type = "number")] - pub started_at_ms: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Temporary notification payload for approval auto-review. This -/// shape is expected to change soon. -pub struct ItemGuardianApprovalReviewStartedNotification { - pub thread_id: String, - pub turn_id: String, - /// Stable identifier for this review. - pub review_id: String, - /// Identifier for the reviewed item or tool call when one exists. - /// - /// In most cases, one review maps to one target item. The exceptions are - /// - execve reviews, where a single command may contain multiple execve - /// calls to review (only possible when using the shell_zsh_fork feature) - /// - network policy reviews, where there is no target item - /// - /// A network call is triggered by a CommandExecution item, so having a - /// target_item_id set to the CommandExecution item would be misleading - /// because the review is about the network call, not the command execution. - /// Therefore, target_item_id is set to None for network policy reviews. - pub target_item_id: Option, - pub review: GuardianApprovalReview, - pub action: GuardianApprovalReviewAction, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Temporary notification payload for approval auto-review. This -/// shape is expected to change soon. -pub struct ItemGuardianApprovalReviewCompletedNotification { - pub thread_id: String, - pub turn_id: String, - /// Stable identifier for this review. - pub review_id: String, - /// Identifier for the reviewed item or tool call when one exists. - /// - /// In most cases, one review maps to one target item. The exceptions are - /// - execve reviews, where a single command may contain multiple execve - /// calls to review (only possible when using the shell_zsh_fork feature) - /// - network policy reviews, where there is no target item - /// - /// A network call is triggered by a CommandExecution item, so having a - /// target_item_id set to the CommandExecution item would be misleading - /// because the review is about the network call, not the command execution. - /// Therefore, target_item_id is set to None for network policy reviews. - pub target_item_id: Option, - pub decision_source: AutoReviewDecisionSource, - pub review: GuardianApprovalReview, - pub action: GuardianApprovalReviewAction, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemCompletedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, - /// Unix timestamp (in milliseconds) when this item lifecycle completed. - #[ts(type = "number")] - pub completed_at_ms: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RawResponseItemCompletedNotification { - pub thread_id: String, - pub turn_id: String, - pub item: ResponseItem, -} - -// Item-specific progress notifications -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AgentMessageDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should -/// not assume concatenated deltas match the completed plan item content. -pub struct PlanDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryPartAddedNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub content_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TerminalInteractionNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub process_id: String, - pub stdin: String, -} - -#[serde_as] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -/// Base64-encoded output chunk emitted for a streaming `command/exec` request. -/// -/// These notifications are connection-scoped. If the originating connection -/// closes, the server terminates the process. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecOutputDeltaNotification { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// Output stream for this chunk. - pub stream: CommandExecOutputStream, - /// Base64-encoded output bytes. - pub delta_base64: String, - /// `true` on the final streamed chunk for a stream when `outputBytesCap` - /// truncated later output on that stream. - pub cap_reached: bool, -} - -/// Deprecated legacy notification for `apply_patch` textual output. -/// -/// The server no longer emits this notification. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangePatchUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub changes: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ServerRequestResolvedNotification { - pub thread_id: String, - pub request_id: RequestId, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallProgressNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginCompletedNotification { - pub name: String, - pub success: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpServerStartupState { - Starting, - Ready, - Failed, - Cancelled, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerStatusUpdatedNotification { - pub name: String, - pub status: McpServerStartupState, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsWorldWritableWarningNotification { - pub sample_paths: Vec, - pub extra_count: usize, - pub failed_scan: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WindowsSandboxSetupMode { - Elevated, - Unelevated, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupStartParams { - pub mode: WindowsSandboxSetupMode, - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupStartResponse { - pub started: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupCompletedNotification { - pub mode: WindowsSandboxSetupMode, - pub success: bool, - pub error: Option, -} - -/// Deprecated: Use `ContextCompaction` item type instead. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ContextCompactedNotification { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Unique identifier for this specific approval callback. - /// - /// For regular shell/unified_exec approvals, this is null. - /// - /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to - /// one parent `itemId`, so `approvalId` is a distinct opaque callback id - /// (a UUID) used to disambiguate routing. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub approval_id: Option, - /// Optional explanatory reason (e.g. request for network access). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub reason: Option, - /// Optional context for a managed-network approval prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub network_approval_context: Option, - /// The command to be executed. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command: Option, - /// The command's working directory. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub cwd: Option, - /// Best-effort parsed command actions for friendly display. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command_actions: Option>, - /// Optional additional permissions requested for this command. - #[experimental("item/commandExecution/requestApproval.additionalPermissions")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub additional_permissions: Option, - /// Optional proposed execpolicy amendment to allow similar commands without prompting. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub proposed_execpolicy_amendment: Option, - /// Optional proposed network policy amendments (allow/deny host) for future requests. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub proposed_network_policy_amendments: Option>, - /// Ordered list of decisions the client may present for this prompt. - #[experimental("item/commandExecution/requestApproval.availableDecisions")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub available_decisions: Option>, -} - -impl CommandExecutionRequestApprovalParams { - pub fn strip_experimental_fields(&mut self) { - // TODO: Avoid hardcoding individual experimental fields here. - // We need a generic outbound compatibility design for stripping or - // otherwise handling experimental server->client payloads. - self.additional_permissions = None; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalResponse { - pub decision: CommandExecutionApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Optional explanatory reason (e.g. request for extra write access). - #[ts(optional = nullable)] - pub reason: Option, - /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root - /// for the remainder of the session (unclear if this is honored today). - #[ts(optional = nullable)] - pub grant_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalResponse { - pub decision: FileChangeApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpServerElicitationAction { - Accept, - Decline, - Cancel, -} - -impl McpServerElicitationAction { - pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { - match self { - Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, - Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, - Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, - } - } -} - -impl From for rmcp::model::ElicitationAction { - fn from(value: McpServerElicitationAction) -> Self { - match value { - McpServerElicitationAction::Accept => Self::Accept, - McpServerElicitationAction::Decline => Self::Decline, - McpServerElicitationAction::Cancel => Self::Cancel, - } - } -} - -impl From for McpServerElicitationAction { - fn from(value: rmcp::model::ElicitationAction) -> Self { - match value { - rmcp::model::ElicitationAction::Accept => Self::Accept, - rmcp::model::ElicitationAction::Decline => Self::Decline, - rmcp::model::ElicitationAction::Cancel => Self::Cancel, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerElicitationRequestParams { - pub thread_id: String, - /// Active Codex turn when this elicitation was observed, if app-server could correlate one. - /// - /// This is nullable because MCP models elicitation as a standalone server-to-client request - /// identified by the MCP server request id. It may be triggered during a turn, but turn - /// context is app-server correlation rather than part of the protocol identity of the - /// elicitation itself. - pub turn_id: Option, - pub server_name: String, - #[serde(flatten)] - pub request: McpServerElicitationRequest, - // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated - // McpToolCall item id here as an optional field. The current core event does not carry that - // association. -} - -/// Typed form schema for MCP `elicitation/create` requests. -/// -/// This matches the `requestedSchema` shape from the MCP 2025-11-25 -/// `ElicitRequestFormParams` schema. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationSchema { - #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] - #[ts(optional, rename = "$schema")] - pub schema_uri: Option, - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationObjectType, - pub properties: BTreeMap, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub required: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationObjectType { - Object, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationPrimitiveSchema { - Enum(McpElicitationEnumSchema), - String(McpElicitationStringSchema), - Number(McpElicitationNumberSchema), - Boolean(McpElicitationBooleanSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationStringSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_length: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_length: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationStringType { - String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum McpElicitationStringFormat { - Email, - Uri, - Date, - DateTime, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationNumberSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationNumberType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub minimum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub maximum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationNumberType { - Number, - Integer, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationBooleanSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationBooleanType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationBooleanType { - Boolean, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationEnumSchema { - SingleSelect(McpElicitationSingleSelectEnumSchema), - MultiSelect(McpElicitationMultiSelectEnumSchema), - Legacy(McpElicitationLegacyTitledEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationLegacyTitledEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, - #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] - #[ts(optional, rename = "enumNames")] - pub enum_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationSingleSelectEnumSchema { - Untitled(McpElicitationUntitledSingleSelectEnumSchema), - Titled(McpElicitationTitledSingleSelectEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledSingleSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledSingleSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "oneOf")] - #[ts(rename = "oneOf")] - pub one_of: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationMultiSelectEnumSchema { - Untitled(McpElicitationUntitledMultiSelectEnumSchema), - Titled(McpElicitationTitledMultiSelectEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledMultiSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationArrayType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_items: Option, - pub items: McpElicitationUntitledEnumItems, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledMultiSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationArrayType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_items: Option, - pub items: McpElicitationTitledEnumItems, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationArrayType { - Array, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledEnumItems { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledEnumItems { - #[serde(rename = "anyOf", alias = "oneOf")] - #[ts(rename = "anyOf")] - pub any_of: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationConstOption { - #[serde(rename = "const")] - #[ts(rename = "const")] - pub const_: String, - pub title: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "mode", rename_all = "camelCase")] -#[ts(tag = "mode")] -#[ts(export_to = "v2/")] -pub enum McpServerElicitationRequest { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Form { - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - meta: Option, - message: String, - requested_schema: McpElicitationSchema, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Url { - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - meta: Option, - message: String, - url: String, - elicitation_id: String, - }, -} - -impl TryFrom for McpServerElicitationRequest { - type Error = serde_json::Error; - - fn try_from(value: CoreElicitationRequest) -> Result { - match value { - CoreElicitationRequest::Form { - meta, - message, - requested_schema, - } => Ok(Self::Form { - meta, - message, - requested_schema: serde_json::from_value(requested_schema)?, - }), - CoreElicitationRequest::Url { - meta, - message, - url, - elicitation_id, - } => Ok(Self::Url { - meta, - message, - url, - elicitation_id, - }), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerElicitationRequestResponse { - pub action: McpServerElicitationAction, - /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. - /// - /// This is nullable because decline/cancel responses have no content. - pub content: Option, - /// Optional client metadata for form-mode action handling. - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - pub meta: Option, -} - -impl From for rmcp::model::CreateElicitationResult { - fn from(value: McpServerElicitationRequestResponse) -> Self { - Self { - action: value.action.into(), - content: value.content, - } - } -} - -impl From for McpServerElicitationRequestResponse { - fn from(value: rmcp::model::CreateElicitationResult) -> Self { - Self { - action: value.action.into(), - content: value.content, - meta: None, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallParams { - pub thread_id: String, - pub turn_id: String, - pub call_id: String, - pub namespace: Option, - pub tool: String, - pub arguments: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionsRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub cwd: AbsolutePathBuf, - pub reason: Option, - pub permissions: RequestPermissionProfile, -} - -v2_enum_from_core!( - #[derive(Default)] - pub enum PermissionGrantScope from CorePermissionGrantScope { - #[default] - Turn, - Session - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionsRequestApprovalResponse { - pub permissions: GrantedPermissionProfile, - #[serde(default)] - pub scope: PermissionGrantScope, - /// Review every subsequent command in this turn before normal sandboxed execution. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub strict_auto_review: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallResponse { - pub content_items: Vec, - pub success: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum DynamicToolCallOutputContentItem { - #[serde(rename_all = "camelCase")] - InputText { text: String }, - #[serde(rename_all = "camelCase")] - InputImage { image_url: String }, -} - -impl From - for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem -{ - fn from(item: DynamicToolCallOutputContentItem) -> Self { - match item { - DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, - DynamicToolCallOutputContentItem::InputImage { image_url } => { - Self::InputImage { image_url } - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Defines a single selectable option for request_user_input. -pub struct ToolRequestUserInputOption { - pub label: String, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Represents one request_user_input question and its required options. -pub struct ToolRequestUserInputQuestion { - pub id: String, - pub header: String, - pub question: String, - #[serde(default)] - pub is_other: bool, - #[serde(default)] - pub is_secret: bool, - pub options: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Params sent with a request_user_input event. -pub struct ToolRequestUserInputParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub questions: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. -pub struct ToolRequestUserInputAnswer { - pub answers: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Response payload mapping question ids to answers. -pub struct ToolRequestUserInputResponse { - pub answers: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountRateLimitsUpdatedNotification { - pub rate_limits: RateLimitSnapshot, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitSnapshot { - pub limit_id: Option, - pub limit_name: Option, - pub primary: Option, - pub secondary: Option, - pub credits: Option, - pub plan_type: Option, - pub rate_limit_reached_type: Option, -} - -impl From for RateLimitSnapshot { - fn from(value: CoreRateLimitSnapshot) -> Self { - Self { - limit_id: value.limit_id, - limit_name: value.limit_name, - primary: value.primary.map(RateLimitWindow::from), - secondary: value.secondary.map(RateLimitWindow::from), - credits: value.credits.map(CreditsSnapshot::from), - plan_type: value.plan_type, - rate_limit_reached_type: value - .rate_limit_reached_type - .map(RateLimitReachedType::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum RateLimitReachedType { - RateLimitReached, - WorkspaceOwnerCreditsDepleted, - WorkspaceMemberCreditsDepleted, - WorkspaceOwnerUsageLimitReached, - WorkspaceMemberUsageLimitReached, -} - -impl From for RateLimitReachedType { - fn from(value: CoreRateLimitReachedType) -> Self { - match value { - CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, - CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - Self::WorkspaceOwnerCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { - Self::WorkspaceMemberCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - Self::WorkspaceOwnerUsageLimitReached - } - CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { - Self::WorkspaceMemberUsageLimitReached - } - } - } -} - -impl From for CoreRateLimitReachedType { - fn from(value: RateLimitReachedType) -> Self { - match value { - RateLimitReachedType::RateLimitReached => Self::RateLimitReached, - RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - Self::WorkspaceOwnerCreditsDepleted - } - RateLimitReachedType::WorkspaceMemberCreditsDepleted => { - Self::WorkspaceMemberCreditsDepleted - } - RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - Self::WorkspaceOwnerUsageLimitReached - } - RateLimitReachedType::WorkspaceMemberUsageLimitReached => { - Self::WorkspaceMemberUsageLimitReached - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitWindow { - pub used_percent: i32, - #[ts(type = "number | null")] - pub window_duration_mins: Option, - #[ts(type = "number | null")] - pub resets_at: Option, -} - -impl From for RateLimitWindow { - fn from(value: CoreRateLimitWindow) -> Self { - Self { - used_percent: value.used_percent.round() as i32, - window_duration_mins: value.window_minutes, - resets_at: value.resets_at, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CreditsSnapshot { - pub has_credits: bool, - pub unlimited: bool, - pub balance: Option, -} - -impl From for CreditsSnapshot { - fn from(value: CoreCreditsSnapshot) -> Self { - Self { - has_credits: value.has_credits, - unlimited: value.unlimited, - balance: value.balance, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountLoginCompletedNotification { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - pub login_id: Option, - pub success: bool, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelReroutedNotification { - pub thread_id: String, - pub turn_id: String, - pub from_model: String, - pub to_model: String, - pub reason: ModelRerouteReason, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelVerificationNotification { - pub thread_id: String, - pub turn_id: String, - pub verifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeprecationNoticeNotification { - /// Concise summary of what is deprecated. - pub summary: String, - /// Optional extra guidance, such as migration steps or rationale. - pub details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WarningNotification { - /// Optional thread target when the warning applies to a specific thread. - pub thread_id: Option, - /// Concise warning message for the user. - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianWarningNotification { - /// Thread target for the guardian warning. - pub thread_id: String, - /// Concise guardian warning message for the user. - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextPosition { - /// 1-based line number. - pub line: usize, - /// 1-based column number (in Unicode scalar values). - pub column: usize, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextRange { - pub start: TextPosition, - pub end: TextPosition, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWarningNotification { - /// Concise summary of the warning. - pub summary: String, - /// Optional extra guidance or error details. - pub details: Option, - /// Optional path to the config file that triggered the warning. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub path: Option, - /// Optional range for the error location inside the config file. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub range: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::items::AgentMessageContent; - use codex_protocol::items::AgentMessageItem; - use codex_protocol::items::FileChangeItem; - use codex_protocol::items::ImageViewItem; - use codex_protocol::items::McpToolCallItem; - use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; - use codex_protocol::items::ReasoningItem; - use codex_protocol::items::TurnItem; - use codex_protocol::items::UserMessageItem; - use codex_protocol::items::WebSearchItem; - use codex_protocol::mcp::CallToolResult; - use codex_protocol::models::WebSearchAction as CoreWebSearchAction; - use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; - use codex_protocol::user_input::UserInput as CoreUserInput; - use codex_utils_absolute_path::test_support::PathBufExt; - use codex_utils_absolute_path::test_support::test_path_buf; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::num::NonZeroUsize; - use std::path::PathBuf; - use std::time::Duration; - - fn absolute_path_string(path: &str) -> String { - let path = format!("/{}", path.trim_start_matches('/')); - test_path_buf(&path).display().to_string() - } - - fn absolute_path(path: &str) -> AbsolutePathBuf { - let path = format!("/{}", path.trim_start_matches('/')); - test_path_buf(&path).abs() - } - - fn test_absolute_path() -> AbsolutePathBuf { - absolute_path("readable") - } - - #[test] - fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { - assert_eq!( - serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), - "\"user\"" - ); - assert_eq!( - serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), - "\"guardian_subagent\"" - ); - - for value in ["user", "auto_review", "guardian_subagent"] { - let json = format!("\"{value}\""); - let reviewer: ApprovalsReviewer = - serde_json::from_str(&json).expect("deserialize reviewer"); - let expected = if value == "user" { - ApprovalsReviewer::User - } else { - ApprovalsReviewer::AutoReview - }; - assert_eq!(expected, reviewer); - } - } - - #[test] - fn thread_list_params_accepts_single_cwd() { - let params = serde_json::from_value::(json!({ - "cwd": "/workspace", - })) - .expect("single cwd should deserialize"); - - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::One("/workspace".to_string())) - ); - assert!(!params.use_state_db_only); - } - - #[test] - fn thread_list_params_accepts_multiple_cwds() { - let params = serde_json::from_value::(json!({ - "cwd": ["/workspace", "/other-workspace"], - })) - .expect("cwd array should deserialize"); - - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::Many(vec![ - "/workspace".to_string(), - "/other-workspace".to_string(), - ])) - ); - } - - #[test] - fn thread_list_params_accepts_state_db_only_flag() { - let params = serde_json::from_value::(json!({ - "useStateDbOnly": true, - })) - .expect("state db only flag should deserialize"); - - assert!(params.use_state_db_only); - } - - #[test] - fn collab_agent_state_maps_interrupted_status() { - assert_eq!( - CollabAgentState::from(CoreAgentStatus::Interrupted), - CollabAgentState { - status: CollabAgentStatus::Interrupted, - message: None, - } - ); - } - - #[test] - fn external_agent_config_plugins_details_round_trip() { - let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ - "itemType": "PLUGINS", - "description": "Install supported plugins from Claude settings", - "cwd": absolute_path_string("repo"), - "details": { - "plugins": [ - { - "marketplaceName": "team-marketplace", - "pluginNames": ["asana"] - } - ] - } - })) - .expect("plugins migration item should deserialize"); - - assert_eq!( - item, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: "Install supported plugins from Claude settings".to_string(), - cwd: Some(PathBuf::from(absolute_path_string("repo"))), - details: Some(MigrationDetails { - plugins: vec![PluginsMigration { - marketplace_name: "team-marketplace".to_string(), - plugin_names: vec!["asana".to_string()], - }], - ..Default::default() - }), - } - ); - } - - #[test] - fn external_agent_config_import_params_accept_legacy_plugin_details() { - let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({ - "migrationItems": [{ - "itemType": "PLUGINS", - "description": "Install supported plugins from Claude settings", - "cwd": absolute_path_string("repo"), - "details": { - "plugins": [ - { - "marketplaceName": "team-marketplace", - "pluginNames": ["asana"] - } - ] - } - }] - })) - .expect("legacy plugin import params should deserialize"); - - assert_eq!( - params, - ExternalAgentConfigImportParams { - migration_items: vec![ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: "Install supported plugins from Claude settings".to_string(), - cwd: Some(PathBuf::from(absolute_path_string("repo"))), - details: Some(MigrationDetails { - plugins: vec![PluginsMigration { - marketplace_name: "team-marketplace".to_string(), - plugin_names: vec!["asana".to_string()], - }], - ..Default::default() - }), - }], - } - ); - } - - #[test] - fn command_execution_request_approval_rejects_relative_additional_permission_paths() { - let err = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": absolute_path_string("tmp"), - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": { - "network": null, - "fileSystem": { - "read": ["relative/path"], - "write": null - } - }, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect_err("relative additional permission paths should fail"); - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); - } - - #[test] - fn permissions_request_approval_uses_request_permission_profile() { - let read_only_path = if cfg!(windows) { - r"C:\tmp\read-only" - } else { - "/tmp/read-only" - }; - let read_write_path = if cfg!(windows) { - r"C:\tmp\read-write" - } else { - "/tmp/read-write" - }; - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "cwd": absolute_path_string("repo"), - "reason": "Select a workspace root", - "permissions": { - "network": { - "enabled": true, - }, - "fileSystem": { - "read": [read_only_path], - "write": [read_write_path], - }, - }, - })) - .expect("permissions request should deserialize"); - - assert_eq!(params.cwd, absolute_path("repo")); - assert_eq!( - params.permissions, - RequestPermissionProfile { - network: Some(AdditionalNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - glob_scan_max_depth: None, - entries: None, - }), - } - ); - - assert_eq!( - CoreRequestPermissionProfile::from(params.permissions), - CoreRequestPermissionProfile { - network: Some(CoreNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(CoreFileSystemPermissions::from_read_write_roots( - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - )), - } - ); - } - - #[test] - fn permissions_request_approval_rejects_macos_permissions() { - let err = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "cwd": absolute_path_string("repo"), - "reason": "Select a workspace root", - "permissions": { - "network": null, - "fileSystem": null, - "macos": { - "preferences": "read_only", - "automations": "none", - "launchServices": false, - "accessibility": false, - "calendar": false, - "reminders": false, - "contacts": "none", - }, - }, - })) - .expect_err("permissions request should reject macos permissions"); - - assert!( - err.to_string().contains("unknown field `macos`"), - "unexpected error: {err}" - ); - } - - #[test] - fn additional_file_system_permissions_preserves_canonical_entries() { - let core_permissions = CoreFileSystemPermissions { - entries: vec![ - CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::Special { - value: CoreFileSystemSpecialPath::Root, - }, - access: CoreFileSystemAccessMode::Write, - }, - CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }, - ], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - assert_eq!( - permissions, - AdditionalFileSystemPermissions { - read: None, - write: None, - glob_scan_max_depth: NonZeroUsize::new(2), - entries: Some(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }, - ]), - } - ); - assert_eq!( - CoreFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn additional_file_system_permissions_populates_entries_for_legacy_roots() { - let read_only_path = absolute_path("read-only"); - let read_write_path = absolute_path("read-write"); - let core_permissions = CoreFileSystemPermissions::from_read_write_roots( - Some(vec![read_only_path.clone()]), - Some(vec![read_write_path.clone()]), - ); - - let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - AdditionalFileSystemPermissions { - read: Some(vec![read_only_path.clone()]), - write: Some(vec![read_write_path.clone()]), - glob_scan_max_depth: None, - entries: Some(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: read_only_path, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: read_write_path, - }, - access: FileSystemAccessMode::Write, - }, - ]), - } - ); - assert_eq!( - CoreFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "read": null, - "write": null, - "globScanMaxDepth": 0, - "entries": [], - })) - .expect_err("zero glob scan depth should fail deserialization"); - } - - #[test] - fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { - let core_permissions = CoreManagedFileSystemPermissions::Restricted { - entries: vec![CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - } - ); - assert_eq!( - CoreManagedFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "type": "restricted", - "entries": [], - "globScanMaxDepth": 0, - })) - .expect_err("zero glob scan depth should fail deserialization"); - } - - #[test] - fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { - let special_path = serde_json::from_value::(json!({ - "kind": "current_working_directory", - })) - .expect("legacy cwd special path should deserialize"); - - assert_eq!( - special_path, - FileSystemSpecialPath::ProjectRoots { subpath: None } - ); - assert_eq!( - serde_json::to_value(&special_path).expect("serialize special path"), - json!({ - "kind": "project_roots", - "subpath": null, - }) - ); - } - - #[test] - fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { - let read_only_path = if cfg!(windows) { - r"C:\tmp\read-only" - } else { - "/tmp/read-only" - }; - let read_write_path = if cfg!(windows) { - r"C:\tmp\read-write" - } else { - "/tmp/read-write" - }; - let response = serde_json::from_value::(json!({ - "permissions": { - "network": { - "enabled": true, - }, - "fileSystem": { - "read": [read_only_path], - "write": [read_write_path], - }, - }, - })) - .expect("permissions response should deserialize"); - - assert_eq!( - response.permissions, - GrantedPermissionProfile { - network: Some(AdditionalNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - glob_scan_max_depth: None, - entries: None, - }), - } - ); - - assert_eq!( - CoreAdditionalPermissionProfile::from(response.permissions), - CoreAdditionalPermissionProfile { - network: Some(CoreNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(CoreFileSystemPermissions::from_read_write_roots( - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - )), - } - ); - } - - #[test] - fn permissions_request_approval_response_defaults_scope_to_turn() { - let response = serde_json::from_value::(json!({ - "permissions": {}, - })) - .expect("response should deserialize"); - - assert_eq!(response.scope, PermissionGrantScope::Turn); - assert_eq!(response.strict_auto_review, None); - } - - #[test] - fn permissions_request_approval_response_accepts_strict_auto_review() { - let response = serde_json::from_value::(json!({ - "permissions": {}, - "strictAutoReview": true, - })) - .expect("response should deserialize"); - - assert_eq!(response.strict_auto_review, Some(true)); - } - - #[test] - fn fs_get_metadata_response_round_trips_minimal_fields() { - let response = FsGetMetadataResponse { - is_directory: false, - is_file: true, - is_symlink: false, - created_at_ms: 123, - modified_at_ms: 456, - }; - - let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); - assert_eq!( - value, - json!({ - "isDirectory": false, - "isFile": true, - "isSymlink": false, - "createdAtMs": 123, - "modifiedAtMs": 456, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/getMetadata response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_read_file_response_round_trips_base64_data() { - let response = FsReadFileResponse { - data_base64: "aGVsbG8=".to_string(), - }; - - let value = serde_json::to_value(&response).expect("serialize fs/readFile response"); - assert_eq!( - value, - json!({ - "dataBase64": "aGVsbG8=", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/readFile response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_read_file_params_round_trip() { - let params = FsReadFileParams { - path: absolute_path("tmp/example.txt"), - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example.txt"), - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/readFile params"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_create_params_round_trip_uses_protection_policy() { - let params = DeviceKeyCreateParams { - protection_policy: None, - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize device/key/create params"); - assert_eq!( - value, - json!({ - "accountUserId": "account-user-1", - "clientId": "cli_123", - "protectionPolicy": null, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/create params"); - assert_eq!(decoded, params); - - let params = DeviceKeyCreateParams { - protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - }; - let value = serde_json::to_value(¶ms) - .expect("serialize device/key/create params with protection policy"); - assert_eq!( - value, - json!({ - "accountUserId": "account-user-1", - "clientId": "cli_123", - "protectionPolicy": "allow_os_protected_nonextractable", - }) - ); - } - - #[test] - fn device_key_create_response_round_trips_protection_class() { - let response = DeviceKeyCreateResponse { - key_id: "dk_123".to_string(), - public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(), - algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, - protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable, - }; - - let value = serde_json::to_value(&response).expect("serialize device/key/create response"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE", - "algorithm": "ecdsa_p256_sha256", - "protectionClass": "os_protected_nonextractable", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/create response"); - assert_eq!(decoded, response); - } - - #[test] - fn device_key_sign_params_round_trip_uses_accepted_payload_enum() { - let params = DeviceKeySignParams { - key_id: "dk_123".to_string(), - payload: DeviceKeySignPayload::RemoteControlClientConnection { - nonce: "nonce-1".to_string(), - audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket, - session_id: "wssess_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/api/codex/remote/control/client".to_string(), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(), - token_expires_at: 1_700_000_000, - scopes: vec!["remote_control_controller_websocket".to_string()], - }, - }; - - let value = serde_json::to_value(¶ms).expect("serialize device/key/sign params"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "payload": { - "type": "remoteControlClientConnection", - "nonce": "nonce-1", - "audience": "remote_control_client_websocket", - "sessionId": "wssess_123", - "targetOrigin": "https://chatgpt.com", - "targetPath": "/api/codex/remote/control/client", - "accountUserId": "account-user-1", - "clientId": "cli_123", - "tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", - "tokenExpiresAt": 1_700_000_000, - "scopes": ["remote_control_controller_websocket"], - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign params"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_sign_params_round_trip_uses_enrollment_payload() { - let params = DeviceKeySignParams { - key_id: "dk_123".to_string(), - payload: DeviceKeySignPayload::RemoteControlClientEnrollment { - nonce: "nonce-1".to_string(), - audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment, - challenge_id: "rch_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/wham/remote/control/client/enroll".to_string(), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" - .to_string(), - challenge_expires_at: 1_700_000_000, - }, - }; - - let value = serde_json::to_value(¶ms) - .expect("serialize device/key/sign params with enrollment payload"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "payload": { - "type": "remoteControlClientEnrollment", - "nonce": "nonce-1", - "audience": "remote_control_client_enrollment", - "challengeId": "rch_123", - "targetOrigin": "https://chatgpt.com", - "targetPath": "/wham/remote/control/client/enroll", - "accountUserId": "account-user-1", - "clientId": "cli_123", - "deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", - "challengeExpiresAt": 1_700_000_000, - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign params with enrollment payload"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_sign_response_returns_signed_payload_bytes() { - let response = DeviceKeySignResponse { - signature_der_base64: "MEUCIQD".to_string(), - signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(), - algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, - }; - - let value = serde_json::to_value(&response).expect("serialize device/key/sign response"); - assert_eq!( - value, - json!({ - "signatureDerBase64": "MEUCIQD", - "signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA", - "algorithm": "ecdsa_p256_sha256", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_create_directory_params_round_trip_with_default_recursive() { - let params = FsCreateDirectoryParams { - path: absolute_path("tmp/example"), - recursive: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example"), - "recursive": null, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/createDirectory params"); - assert_eq!(decoded, params); - } - - #[test] - fn fs_write_file_params_round_trip_with_base64_data() { - let params = FsWriteFileParams { - path: absolute_path("tmp/example.bin"), - data_base64: "AAE=".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example.bin"), - "dataBase64": "AAE=", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/writeFile params"); - assert_eq!(decoded, params); - } - - #[test] - fn fs_copy_params_round_trip_with_recursive_directory_copy() { - let params = FsCopyParams { - source_path: absolute_path("tmp/source"), - destination_path: absolute_path("tmp/destination"), - recursive: true, - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); - assert_eq!( - value, - json!({ - "sourcePath": absolute_path_string("tmp/source"), - "destinationPath": absolute_path_string("tmp/destination"), - "recursive": true, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize fs/copy params"); - assert_eq!(decoded, params); - } - - #[test] - fn thread_shell_command_params_round_trip() { - let params = ThreadShellCommandParams { - thread_id: "thr_123".to_string(), - command: "printf 'hello world\\n'".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); - assert_eq!( - value, - json!({ - "threadId": "thr_123", - "command": "printf 'hello world\\n'", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize thread/shellCommand params"); - assert_eq!(decoded, params); - } - - #[test] - fn thread_shell_command_response_round_trip() { - let response = ThreadShellCommandResponse {}; - - let value = - serde_json::to_value(&response).expect("serialize thread/shellCommand response"); - assert_eq!(value, json!({})); - - let decoded = serde_json::from_value::(value) - .expect("deserialize thread/shellCommand response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_changed_notification_round_trips() { - let notification = FsChangedNotification { - watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), - changed_paths: vec![ - absolute_path("tmp/repo/.git/HEAD"), - absolute_path("tmp/repo/.git/FETCH_HEAD"), - ], - }; - - let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); - assert_eq!( - value, - json!({ - "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", - "changedPaths": [ - absolute_path_string("tmp/repo/.git/HEAD"), - absolute_path_string("tmp/repo/.git/FETCH_HEAD"), - ], - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/changed notification"); - assert_eq!(decoded, notification); - } - - #[test] - fn command_exec_params_default_optional_streaming_flags() { - let params = serde_json::from_value::(json!({ - "command": ["ls", "-la"], - "timeoutMs": 1000, - "cwd": "/tmp" - })) - .expect("command/exec payload should deserialize"); - - assert_eq!( - params, - CommandExecParams { - command: vec!["ls".to_string(), "-la".to_string()], - process_id: None, - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: Some(1000), - cwd: Some(PathBuf::from("/tmp")), - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - } - ); - } - - #[test] - fn command_exec_params_round_trips_disable_timeout() { - let params = CommandExecParams { - command: vec!["sleep".to_string(), "30".to_string()], - process_id: Some("sleep-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: true, - timeout_ms: None, - cwd: None, - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["sleep", "30"], - "processId": "sleep-1", - "disableTimeout": true, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - "outputBytesCap": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn process_spawn_params_round_trips_without_sandbox_policy() { - let params = ProcessSpawnParams { - command: vec!["sleep".to_string(), "30".to_string()], - process_handle: "sleep-1".to_string(), - cwd: test_absolute_path(), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - timeout_ms: None, - env: None, - size: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize process/spawn params"); - assert_eq!( - value, - json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - "env": null, - "size": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn process_spawn_params_distinguish_omitted_null_and_value_limits() { - let base = json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - }); - - let expected_omitted = ProcessSpawnParams { - command: vec!["sleep".to_string(), "30".to_string()], - process_handle: "sleep-1".to_string(), - cwd: test_absolute_path(), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - timeout_ms: None, - env: None, - size: None, - }; - let decoded = - serde_json::from_value::(base).expect("deserialize omitted limits"); - assert_eq!(decoded, expected_omitted); - - let decoded = serde_json::from_value::(json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - "outputBytesCap": null, - "timeoutMs": null, - })) - .expect("deserialize disabled limits"); - assert_eq!( - decoded, - ProcessSpawnParams { - output_bytes_cap: Some(None), - timeout_ms: Some(None), - ..expected_omitted.clone() - } - ); - - let decoded = serde_json::from_value::(json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - "outputBytesCap": 123, - "timeoutMs": 456, - })) - .expect("deserialize explicit limits"); - assert_eq!( - decoded, - ProcessSpawnParams { - output_bytes_cap: Some(Some(123)), - timeout_ms: Some(Some(456)), - ..expected_omitted - } - ); - } - - #[test] - fn command_exec_params_round_trips_disable_output_cap() { - let params = CommandExecParams { - command: vec!["yes".to_string()], - process_id: Some("yes-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: true, - output_bytes_cap: None, - disable_output_cap: true, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["yes"], - "processId": "yes-1", - "streamStdoutStderr": true, - "outputBytesCap": null, - "disableOutputCap": true, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_params_round_trips_env_overrides_and_unsets() { - let params = CommandExecParams { - command: vec!["printenv".to_string(), "FOO".to_string()], - process_id: Some("env-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: Some(HashMap::from([ - ("FOO".to_string(), Some("override".to_string())), - ("BAR".to_string(), Some("added".to_string())), - ("BAZ".to_string(), None), - ])), - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["printenv", "FOO"], - "processId": "env-1", - "outputBytesCap": null, - "timeoutMs": null, - "cwd": null, - "env": { - "FOO": "override", - "BAR": "added", - "BAZ": null, - }, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_write_round_trips_close_only_payload() { - let params = CommandExecWriteParams { - process_id: "proc-7".to_string(), - delta_base64: None, - close_stdin: true, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); - assert_eq!( - value, - json!({ - "processId": "proc-7", - "deltaBase64": null, - "closeStdin": true, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_terminate_round_trips() { - let params = CommandExecTerminateParams { - process_id: "proc-8".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); - assert_eq!( - value, - json!({ - "processId": "proc-8", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_params_round_trip_with_size() { - let params = CommandExecParams { - command: vec!["top".to_string()], - process_id: Some("pty-1".to_string()), - tty: true, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: None, - size: Some(CommandExecTerminalSize { - rows: 40, - cols: 120, - }), - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["top"], - "processId": "pty-1", - "tty": true, - "outputBytesCap": null, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": { - "rows": 40, - "cols": 120, - }, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_resize_round_trips() { - let params = CommandExecResizeParams { - process_id: "proc-9".to_string(), - size: CommandExecTerminalSize { - rows: 50, - cols: 160, - }, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); - assert_eq!( - value, - json!({ - "processId": "proc-9", - "size": { - "rows": 50, - "cols": 160, - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_output_delta_round_trips() { - let notification = CommandExecOutputDeltaNotification { - process_id: "proc-1".to_string(), - stream: CommandExecOutputStream::Stdout, - delta_base64: "AQI=".to_string(), - cap_reached: false, - }; - - let value = serde_json::to_value(¬ification) - .expect("serialize command/exec/outputDelta notification"); - assert_eq!( - value, - json!({ - "processId": "proc-1", - "stream": "stdout", - "deltaBase64": "AQI=", - "capReached": false, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, notification); - } - - #[test] - fn process_control_params_round_trip() { - let write = ProcessWriteStdinParams { - process_handle: "proc-7".to_string(), - delta_base64: None, - close_stdin: true, - }; - let value = serde_json::to_value(&write).expect("serialize process/writeStdin params"); - assert_eq!( - value, - json!({ - "processHandle": "proc-7", - "deltaBase64": null, - "closeStdin": true, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/writeStdin params"); - assert_eq!(decoded, write); - - let resize = ProcessResizePtyParams { - process_handle: "proc-7".to_string(), - size: ProcessTerminalSize { - rows: 50, - cols: 160, - }, - }; - let value = serde_json::to_value(&resize).expect("serialize process/resizePty params"); - assert_eq!( - value, - json!({ - "processHandle": "proc-7", - "size": { - "rows": 50, - "cols": 160, - }, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/resizePty params"); - assert_eq!(decoded, resize); - - let kill = ProcessKillParams { - process_handle: "proc-7".to_string(), - }; - let value = serde_json::to_value(&kill).expect("serialize process/kill params"); - assert_eq!( - value, - json!({ - "processHandle": "proc-7", - }) - ); - let decoded = - serde_json::from_value::(value).expect("deserialize process/kill"); - assert_eq!(decoded, kill); - } - - #[test] - fn process_notifications_round_trip() { - let delta = ProcessOutputDeltaNotification { - process_handle: "proc-1".to_string(), - stream: ProcessOutputStream::Stdout, - delta_base64: "AQI=".to_string(), - cap_reached: false, - }; - let value = serde_json::to_value(&delta).expect("serialize process/outputDelta"); - assert_eq!( - value, - json!({ - "processHandle": "proc-1", - "stream": "stdout", - "deltaBase64": "AQI=", - "capReached": false, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/outputDelta"); - assert_eq!(decoded, delta); - - let exited = ProcessExitedNotification { - process_handle: "proc-1".to_string(), - exit_code: 0, - stdout: "out".to_string(), - stdout_cap_reached: false, - stderr: "err".to_string(), - stderr_cap_reached: true, - }; - let value = serde_json::to_value(&exited).expect("serialize process/exited"); - assert_eq!( - value, - json!({ - "processHandle": "proc-1", - "exitCode": 0, - "stdout": "out", - "stdoutCapReached": false, - "stderr": "err", - "stderrCapReached": true, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/exited"); - assert_eq!(decoded, exited); - } - - #[test] - fn command_execution_output_delta_round_trips() { - let notification = CommandExecutionOutputDeltaNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - item_id: "item-1".to_string(), - delta: "\u{fffd}a\n".to_string(), - }; - - let value = serde_json::to_value(¬ification) - .expect("serialize item/commandExecution/outputDelta notification"); - assert_eq!( - value, - json!({ - "threadId": "thread-1", - "turnId": "turn-1", - "itemId": "item-1", - "delta": "\u{fffd}a\n", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, notification); - } - - #[test] - fn sandbox_policy_round_trips_external_sandbox_network_access() { - let v2_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: CoreNetworkAccess::Enabled, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn sandbox_policy_round_trips_read_only_network_access() { - let v2_policy = SandboxPolicy::ReadOnly { - network_access: true, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::ReadOnly { - network_access: true, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn ask_for_approval_granular_round_trips_request_permissions_flag() { - let v2_policy = AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - CoreAskForApproval::Granular(CoreGranularApprovalConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }) - ); - - let back_to_v2 = AskForApproval::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() { - let decoded = serde_json::from_value::(serde_json::json!({ - "granular": { - "sandbox_approval": true, - "rules": false, - "mcp_elicitations": true, - } - })) - .expect("granular approval policy should deserialize"); - - assert_eq!( - decoded, - AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - } - ); - } - - #[test] - fn ask_for_approval_granular_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason( - &AskForApproval::OnRequest, - ), - None - ); - } - - #[test] - fn profile_v2_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); - } - - #[test] - fn config_nested_profile_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_nested_profile_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); - } - - #[test] - fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { - let reason = - crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { - allowed_approval_policies: Some(vec![AskForApproval::Granular { - sandbox_approval: true, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_web_search_modes: None, - feature_requirements: None, - hooks: None, - enforce_residency: None, - network: None, - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadStart { - request_id: crate::RequestId::Integer(1), - params: ThreadStartParams { - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadResume { - request_id: crate::RequestId::Integer(2), - params: ThreadResumeParams { - thread_id: "thr_123".to_string(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadFork { - request_id: crate::RequestId::Integer(3), - params: ThreadForkParams { - thread_id: "thr_456".to_string(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::TurnStart { - request_id: crate::RequestId::Integer(4), - params: TurnStartParams { - thread_id: "thr_123".to_string(), - input: Vec::new(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn mcp_server_elicitation_response_round_trips_rmcp_result() { - let rmcp_result = rmcp::model::CreateElicitationResult { - action: rmcp::model::ElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - }; - - let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); - assert_eq!( - v2_response, - McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - meta: None, - } - ); - assert_eq!( - rmcp::model::CreateElicitationResult::from(v2_response), - rmcp_result - ); - } - - #[test] - fn mcp_server_elicitation_request_from_core_url_request() { - let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { - meta: None, - message: "Finish sign-in".to_string(), - url: "https://example.com/complete".to_string(), - elicitation_id: "elicitation-123".to_string(), - }) - .expect("URL request should convert"); - - assert_eq!( - request, - McpServerElicitationRequest::Url { - meta: None, - message: "Finish sign-in".to_string(), - url: "https://example.com/complete".to_string(), - elicitation_id: "elicitation-123".to_string(), - } - ); - } - - #[test] - fn mcp_server_elicitation_request_from_core_form_request() { - let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - } - }, - "required": ["confirmed"], - }), - }) - .expect("form request should convert"); - - let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - } - }, - "required": ["confirmed"], - })) - .expect("expected schema should deserialize"); - - assert_eq!( - request, - McpServerElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: expected_schema, - } - ); - } - - #[test] - fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { - let schema: McpElicitationSchema = serde_json::from_value(json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "email": { - "type": "string", - "title": "Email", - "description": "Work email address", - "format": "email", - "default": "dev@example.com", - }, - "count": { - "type": "integer", - "title": "Count", - "description": "How many items to create", - "minimum": 1, - "maximum": 5, - "default": 3, - }, - "confirmed": { - "type": "boolean", - "title": "Confirm", - "description": "Approve the pending action", - "default": true, - }, - "legacyChoice": { - "type": "string", - "title": "Action", - "description": "Legacy titled enum form", - "enum": ["allow", "deny"], - "enumNames": ["Allow", "Deny"], - "default": "allow", - }, - }, - "required": ["email", "confirmed"], - })) - .expect("schema should deserialize"); - - assert_eq!( - schema, - McpElicitationSchema { - schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), - type_: McpElicitationObjectType::Object, - properties: BTreeMap::from([ - ( - "confirmed".to_string(), - McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { - type_: McpElicitationBooleanType::Boolean, - title: Some("Confirm".to_string()), - description: Some("Approve the pending action".to_string()), - default: Some(true), - }), - ), - ( - "count".to_string(), - McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { - type_: McpElicitationNumberType::Integer, - title: Some("Count".to_string()), - description: Some("How many items to create".to_string()), - minimum: Some(1.0), - maximum: Some(5.0), - default: Some(3.0), - }), - ), - ( - "email".to_string(), - McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { - type_: McpElicitationStringType::String, - title: Some("Email".to_string()), - description: Some("Work email address".to_string()), - min_length: None, - max_length: None, - format: Some(McpElicitationStringFormat::Email), - default: Some("dev@example.com".to_string()), - }), - ), - ( - "legacyChoice".to_string(), - McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( - McpElicitationLegacyTitledEnumSchema { - type_: McpElicitationStringType::String, - title: Some("Action".to_string()), - description: Some("Legacy titled enum form".to_string()), - enum_: vec!["allow".to_string(), "deny".to_string()], - enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), - default: Some("allow".to_string()), - }, - )), - ), - ]), - required: Some(vec!["email".to_string(), "confirmed".to_string()]), - } - ); - } - - #[test] - fn mcp_server_elicitation_request_rejects_null_core_form_schema() { - let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: Some(json!({ - "persist": "session", - })), - message: "Allow this request?".to_string(), - requested_schema: JsonValue::Null, - }); - - assert!(result.is_err()); - } - - #[test] - fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { - let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "object", - } - }, - }), - }); - - assert!(result.is_err()); - } - - #[test] - fn mcp_server_elicitation_response_serializes_nullable_content() { - let response = McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Decline, - content: None, - meta: None, - }; - - assert_eq!( - serde_json::to_value(response).expect("response should serialize"), - json!({ - "action": "decline", - "content": null, - "_meta": null, - }) - ); - } - - #[test] - fn sandbox_policy_round_trips_workspace_write_access() { - let v2_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { - let policy = serde_json::from_value::(json!({ - "type": "readOnly", - "access": { - "type": "fullAccess" - }, - "networkAccess": true - })) - .expect("read-only policy should ignore legacy fullAccess field"); - assert_eq!( - policy, - SandboxPolicy::ReadOnly { - network_access: true - } - ); - } - - #[test] - fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { - let writable_root = absolute_path("/workspace"); - let policy = serde_json::from_value::(json!({ - "type": "workspaceWrite", - "writableRoots": [writable_root], - "readOnlyAccess": { - "type": "fullAccess" - }, - "networkAccess": true, - "excludeTmpdirEnvVar": true, - "excludeSlashTmp": true - })) - .expect("workspace-write policy should ignore legacy fullAccess field"); - assert_eq!( - policy, - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path("/workspace")], - network_access: true, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - } - ); - } - - #[test] - fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { - let err = serde_json::from_value::(json!({ - "type": "readOnly", - "access": { - "type": "restricted", - "includePlatformDefaults": false, - "readableRoots": [] - } - })) - .expect_err("read-only policy should reject removed restricted access field"); - assert!(err.to_string().contains("readOnly.access")); - } - - #[test] - fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { - let err = serde_json::from_value::(json!({ - "type": "workspaceWrite", - "writableRoots": [], - "readOnlyAccess": { - "type": "restricted", - "includePlatformDefaults": false, - "readableRoots": [] - }, - "networkAccess": false, - "excludeTmpdirEnvVar": false, - "excludeSlashTmp": false - })) - .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); - assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); - } - - #[test] - fn automatic_approval_review_deserializes_aborted_status() { - let review: GuardianApprovalReview = serde_json::from_value(json!({ - "status": "aborted", - "riskLevel": null, - "userAuthorization": null, - "rationale": null - })) - .expect("aborted automatic review should deserialize"); - assert_eq!( - review, - GuardianApprovalReview { - status: GuardianApprovalReviewStatus::Aborted, - risk_level: None, - user_authorization: None, - rationale: None, - } - ); - } - - #[test] - fn guardian_approval_review_action_round_trips_command_shape() { - let value = json!({ - "type": "command", - "source": "shell", - "command": "rm -rf /tmp/example.sqlite", - "cwd": absolute_path_string("tmp"), - }); - let action: GuardianApprovalReviewAction = - serde_json::from_value(value.clone()).expect("guardian review action"); - - assert_eq!( - action, - GuardianApprovalReviewAction::Command { - source: GuardianCommandSource::Shell, - command: "rm -rf /tmp/example.sqlite".to_string(), - cwd: absolute_path("tmp"), - } - ); - assert_eq!( - serde_json::to_value(&action).expect("serialize guardian review action"), - value - ); - } - - #[test] - fn network_requirements_deserializes_legacy_fields() { - let requirements: NetworkRequirements = serde_json::from_value(json!({ - "allowedDomains": ["api.openai.com"], - "deniedDomains": ["blocked.example.com"], - "allowUnixSockets": ["/tmp/proxy.sock"] - })) - .expect("legacy network requirements should deserialize"); - - assert_eq!( - requirements, - NetworkRequirements { - enabled: None, - http_port: None, - socks_port: None, - allow_upstream_proxy: None, - dangerously_allow_non_loopback_proxy: None, - dangerously_allow_all_unix_sockets: None, - domains: None, - managed_allowed_domains_only: None, - allowed_domains: Some(vec!["api.openai.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - unix_sockets: None, - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), - allow_local_binding: None, - } - ); - } - - #[test] - fn network_requirements_serializes_canonical_and_legacy_fields() { - let requirements = NetworkRequirements { - enabled: Some(true), - http_port: Some(8080), - socks_port: Some(1080), - allow_upstream_proxy: Some(false), - dangerously_allow_non_loopback_proxy: Some(false), - dangerously_allow_all_unix_sockets: Some(true), - domains: Some(BTreeMap::from([ - ("api.openai.com".to_string(), NetworkDomainPermission::Allow), - ( - "blocked.example.com".to_string(), - NetworkDomainPermission::Deny, - ), - ])), - managed_allowed_domains_only: Some(true), - allowed_domains: Some(vec!["api.openai.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - unix_sockets: Some(BTreeMap::from([ - ( - "/tmp/proxy.sock".to_string(), - NetworkUnixSocketPermission::Allow, - ), - ( - "/tmp/ignored.sock".to_string(), - NetworkUnixSocketPermission::None, - ), - ])), - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), - allow_local_binding: Some(true), - }; - - assert_eq!( - serde_json::to_value(requirements).expect("network requirements should serialize"), - json!({ - "enabled": true, - "httpPort": 8080, - "socksPort": 1080, - "allowUpstreamProxy": false, - "dangerouslyAllowNonLoopbackProxy": false, - "dangerouslyAllowAllUnixSockets": true, - "domains": { - "api.openai.com": "allow", - "blocked.example.com": "deny" - }, - "managedAllowedDomainsOnly": true, - "allowedDomains": ["api.openai.com"], - "deniedDomains": ["blocked.example.com"], - "unixSockets": { - "/tmp/ignored.sock": "none", - "/tmp/proxy.sock": "allow" - }, - "allowUnixSockets": ["/tmp/proxy.sock"], - "allowLocalBinding": true - }) - ); - } - - #[test] - fn core_turn_item_into_thread_item_converts_supported_variants() { - let user_item = TurnItem::UserMessage(UserMessageItem { - id: "user-1".to_string(), - content: vec![ - CoreUserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - CoreUserInput::Image { - image_url: "https://example.com/image.png".to_string(), - }, - CoreUserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - CoreUserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), - }, - CoreUserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - computer_use_native_app_bundle_id: None, - }, - ], - }); - - assert_eq!( - ThreadItem::from(user_item), - ThreadItem::UserMessage { - id: "user-1".to_string(), - content: vec![ - UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - UserInput::Image { - url: "https://example.com/image.png".to_string(), - }, - UserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - UserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), - }, - UserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - computer_use_native_app_bundle_id: None, - }, - ], - } - ); - - let agent_item = TurnItem::AgentMessage(AgentMessageItem { - id: "agent-1".to_string(), - content: vec![ - AgentMessageContent::Text { - text: "Hello ".to_string(), - }, - AgentMessageContent::Text { - text: "world".to_string(), - }, - ], - phase: None, - memory_citation: None, - }); - - assert_eq!( - ThreadItem::from(agent_item), - ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "Hello world".to_string(), - phase: None, - memory_citation: None, - } - ); - - let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem { - id: "agent-2".to_string(), - content: vec![AgentMessageContent::Text { - text: "final".to_string(), - }], - phase: Some(MessagePhase::FinalAnswer), - memory_citation: Some(CoreMemoryCitation { - entries: vec![CoreMemoryCitationEntry { - path: "MEMORY.md".to_string(), - line_start: 1, - line_end: 2, - note: "summary".to_string(), - }], - rollout_ids: vec!["rollout-1".to_string()], - }), - }); - - assert_eq!( - ThreadItem::from(agent_item_with_phase), - ThreadItem::AgentMessage { - id: "agent-2".to_string(), - text: "final".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: Some(MemoryCitation { - entries: vec![MemoryCitationEntry { - path: "MEMORY.md".to_string(), - line_start: 1, - line_end: 2, - note: "summary".to_string(), - }], - thread_ids: vec!["rollout-1".to_string()], - }), - } - ); - - let reasoning_item = TurnItem::Reasoning(ReasoningItem { - id: "reasoning-1".to_string(), - summary_text: vec!["line one".to_string(), "line two".to_string()], - raw_content: vec![], - }); - - assert_eq!( - ThreadItem::from(reasoning_item), - ThreadItem::Reasoning { - id: "reasoning-1".to_string(), - summary: vec!["line one".to_string(), "line two".to_string()], - content: vec![], - } - ); - - let search_item = TurnItem::WebSearch(WebSearchItem { - id: "search-1".to_string(), - query: "docs".to_string(), - action: CoreWebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }, - }); - - assert_eq!( - ThreadItem::from(search_item), - ThreadItem::WebSearch { - id: "search-1".to_string(), - query: "docs".to_string(), - action: Some(WebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }), - } - ); - - let image_view_item = TurnItem::ImageView(ImageViewItem { - id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), - }); - - assert_eq!( - ThreadItem::from(image_view_item), - ThreadItem::ImageView { - id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), - } - ); - - let file_change_item = TurnItem::FileChange(FileChangeItem { - id: "patch-1".to_string(), - changes: [( - PathBuf::from("README.md"), - codex_protocol::protocol::FileChange::Add { - content: "hello\n".to_string(), - }, - )] - .into_iter() - .collect(), - status: Some(codex_protocol::protocol::PatchApplyStatus::Completed), - auto_approved: None, - stdout: Some("Done!".to_string()), - stderr: Some(String::new()), - }); - - assert_eq!( - ThreadItem::from(file_change_item), - ThreadItem::FileChange { - id: "patch-1".to_string(), - changes: vec![FileUpdateChange { - path: "README.md".to_string(), - kind: PatchChangeKind::Add, - diff: "hello\n".to_string(), - }], - status: PatchApplyStatus::Completed, - } - ); - - let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { - id: "mcp-1".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - arguments: json!({"arg": "value"}), - mcp_app_resource_uri: Some("app://connector".to_string()), - status: CoreMcpToolCallStatus::InProgress, - result: None, - error: None, - duration: None, - }); - - assert_eq!( - ThreadItem::from(mcp_tool_call_item), - ThreadItem::McpToolCall { - id: "mcp-1".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - status: McpToolCallStatus::InProgress, - arguments: json!({"arg": "value"}), - mcp_app_resource_uri: Some("app://connector".to_string()), - result: None, - error: None, - duration_ms: None, - } - ); - - let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { - id: "mcp-2".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - status: CoreMcpToolCallStatus::Completed, - result: Some(CallToolResult { - content: vec![json!({"type": "text", "text": "ok"})], - structured_content: Some(json!({"ok": true})), - is_error: Some(false), - meta: Some(json!({"trace": "1"})), - }), - error: None, - duration: Some(Duration::from_millis(42)), - }); - - assert_eq!( - ThreadItem::from(completed_mcp_tool_call_item), - ThreadItem::McpToolCall { - id: "mcp-2".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - status: McpToolCallStatus::Completed, - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - result: Some(Box::new(McpToolCallResult { - content: vec![json!({"type": "text", "text": "ok"})], - structured_content: Some(json!({"ok": true})), - meta: Some(json!({"trace": "1"})), - })), - error: None, - duration_ms: Some(42), - } - ); - } - - #[test] - fn skills_list_params_serialization_uses_force_reload() { - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: Vec::new(), - force_reload: false, - per_cwd_extra_user_roots: None, - }) - .unwrap(), - json!({ - "perCwdExtraUserRoots": null, - }), - ); - - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: vec![PathBuf::from("/repo")], - force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: PathBuf::from("/repo"), - extra_user_roots: vec![ - PathBuf::from("/shared/skills"), - PathBuf::from("/tmp/x") - ], - }]), - }) - .unwrap(), - json!({ - "cwds": ["/repo"], - "forceReload": true, - "perCwdExtraUserRoots": [ - { - "cwd": "/repo", - "extraUserRoots": ["/shared/skills", "/tmp/x"], - } - ], - }), - ); - } - - #[test] - fn plugin_source_serializes_local_git_and_remote_variants() { - let local_path = if cfg!(windows) { - r"C:\plugins\linear" - } else { - "/plugins/linear" - }; - let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); - let local_path_json = local_path.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), - json!({ - "type": "local", - "path": local_path_json, - }), - ); - - assert_eq!( - serde_json::to_value(PluginSource::Git { - url: "https://github.com/openai/example.git".to_string(), - path: Some("plugins/example".to_string()), - ref_name: Some("main".to_string()), - sha: Some("abc123".to_string()), - }) - .unwrap(), - json!({ - "type": "git", - "url": "https://github.com/openai/example.git", - "path": "plugins/example", - "refName": "main", - "sha": "abc123", - }), - ); - - assert_eq!( - serde_json::to_value(PluginSource::Remote).unwrap(), - json!({ - "type": "remote", - }), - ); - } - - #[test] - fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { - assert_eq!( - serde_json::to_value(MarketplaceAddParams { - source: "owner/repo".to_string(), - ref_name: None, - sparse_paths: None, - }) - .unwrap(), - json!({ - "source": "owner/repo", - "refName": null, - "sparsePaths": null, - }), - ); - - assert_eq!( - serde_json::to_value(MarketplaceAddParams { - source: "owner/repo".to_string(), - ref_name: Some("main".to_string()), - sparse_paths: Some(vec!["plugins/foo".to_string()]), - }) - .unwrap(), - json!({ - "source": "owner/repo", - "refName": "main", - "sparsePaths": ["plugins/foo"], - }), - ); - } - - #[test] - fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { - assert_eq!( - serde_json::to_value(MarketplaceUpgradeParams { - marketplace_name: None, - }) - .unwrap(), - json!({ - "marketplaceName": null, - }), - ); - - assert_eq!( - serde_json::from_value::(json!({})).unwrap(), - MarketplaceUpgradeParams { - marketplace_name: None, - }, - ); - - assert_eq!( - serde_json::to_value(MarketplaceUpgradeParams { - marketplace_name: Some("debug".to_string()), - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - }), - ); - } - - #[test] - fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { - assert_eq!( - serde_json::to_value(PluginMarketplaceEntry { - name: "openai-curated".to_string(), - path: None, - interface: None, - plugins: Vec::new(), - }) - .unwrap(), - json!({ - "name": "openai-curated", - "path": null, - "interface": null, - "plugins": [], - }), - ); - } - - #[test] - fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { - let composer_icon = if cfg!(windows) { - r"C:\plugins\linear\icon.png" - } else { - "/plugins/linear/icon.png" - }; - let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); - let composer_icon_json = composer_icon.as_path().display().to_string(); - - let interface = PluginInterface { - display_name: Some("Linear".to_string()), - short_description: None, - long_description: None, - developer_name: None, - category: Some("Productivity".to_string()), - capabilities: Vec::new(), - website_url: None, - privacy_policy_url: None, - terms_of_service_url: None, - default_prompt: None, - brand_color: None, - composer_icon: Some(composer_icon), - composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), - logo: None, - logo_url: Some("https://example.com/linear/logo.png".to_string()), - screenshots: Vec::new(), - screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], - }; - - assert_eq!( - serde_json::to_value(interface).unwrap(), - json!({ - "displayName": "Linear", - "shortDescription": null, - "longDescription": null, - "developerName": null, - "category": "Productivity", - "capabilities": [], - "websiteUrl": null, - "privacyPolicyUrl": null, - "termsOfServiceUrl": null, - "defaultPrompt": null, - "brandColor": null, - "composerIcon": composer_icon_json, - "composerIconUrl": "https://example.com/linear/icon.png", - "logo": null, - "logoUrl": "https://example.com/linear/logo.png", - "screenshots": [], - "screenshotUrls": ["https://example.com/linear/screenshot.png"], - }), - ); - } - - #[test] - fn plugin_list_params_ignore_removed_force_remote_sync_field() { - assert_eq!( - serde_json::from_value::(json!({ - "cwds": null, - "forceRemoteSync": true, - })) - .unwrap(), - PluginListParams { cwds: None }, - ); - } - - #[test] - fn plugin_read_params_serialization_uses_install_source_fields() { - let marketplace_path = if cfg!(windows) { - r"C:\plugins\marketplace.json" - } else { - "/plugins/marketplace.json" - }; - let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); - let marketplace_path_json = marketplace_path.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(PluginReadParams { - marketplace_path: Some(marketplace_path.clone()), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }) - .unwrap(), - json!({ - "marketplacePath": marketplace_path_json, - "remoteMarketplaceName": null, - "pluginName": "gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginReadParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }, - ); - - assert_eq!( - serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", - })) - .unwrap(), - PluginReadParams { - marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), - }, - ); - } - - #[test] - fn plugin_install_params_serialization_omits_force_remote_sync() { - let marketplace_path = if cfg!(windows) { - r"C:\plugins\marketplace.json" - } else { - "/plugins/marketplace.json" - }; - let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); - let marketplace_path_json = marketplace_path.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(PluginInstallParams { - marketplace_path: Some(marketplace_path.clone()), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }) - .unwrap(), - json!({ - "marketplacePath": marketplace_path_json, - "remoteMarketplaceName": null, - "pluginName": "gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginInstallParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }, - ); - - assert_eq!( - serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginInstallParams { - marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), - }, - ); - } - - #[test] - fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { - assert_eq!( - serde_json::to_value(PluginSkillReadParams { - remote_marketplace_name: "chatgpt-global".to_string(), - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - skill_name: "plan-work".to_string(), - }) - .unwrap(), - json!({ - "remoteMarketplaceName": "chatgpt-global", - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - "skillName": "plan-work", - }), - ); - } - - #[test] - fn plugin_share_params_and_response_serialization_use_camel_case_fields() { - let plugin_path = if cfg!(windows) { - r"C:\plugins\gmail" - } else { - "/plugins/gmail" - }; - let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); - let plugin_path_json = plugin_path.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(PluginShareSaveParams { - plugin_path: plugin_path.clone(), - remote_plugin_id: None, - }) - .unwrap(), - json!({ - "pluginPath": plugin_path_json, - "remotePluginId": null, - }), - ); - - assert_eq!( - serde_json::to_value(PluginShareSaveParams { - plugin_path, - remote_plugin_id: Some( - "plugins~Plugin_00000000000000000000000000000000".to_string(), - ), - }) - .unwrap(), - json!({ - "pluginPath": plugin_path_json, - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - }), - ); - - assert_eq!( - serde_json::to_value(PluginShareSaveResponse { - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - share_url: String::new(), - }) - .unwrap(), - json!({ - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - "shareUrl": "", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({})).unwrap(), - PluginShareListParams {}, - ); - - assert_eq!( - serde_json::to_value(PluginShareDeleteParams { - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - }) - .unwrap(), - json!({ - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - }), - ); - } - - #[test] - fn plugin_share_list_response_serializes_share_items() { - assert_eq!( - serde_json::to_value(PluginShareListResponse { - data: vec![PluginShareListItem { - plugin: PluginSummary { - id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - name: "gmail".to_string(), - source: PluginSource::Remote, - installed: false, - enabled: false, - install_policy: PluginInstallPolicy::Available, - auth_policy: PluginAuthPolicy::OnUse, - availability: PluginAvailability::Available, - interface: None, - }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), - local_plugin_path: None, - }], - }) - .unwrap(), - json!({ - "data": [{ - "plugin": { - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "gmail", - "source": { "type": "remote" }, - "installed": false, - "enabled": false, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_USE", - "availability": "AVAILABLE", - "interface": null, - }, - "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", - "localPluginPath": null, - }], - }), - ); - } - - #[test] - fn plugin_summary_defaults_missing_availability_to_available() { - let summary: PluginSummary = serde_json::from_value(json!({ - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "gmail", - "source": { "type": "remote" }, - "installed": false, - "enabled": false, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_USE", - "interface": null, - })) - .unwrap(); - - assert_eq!(summary.availability, PluginAvailability::Available); - } - - #[test] - fn plugin_availability_deserializes_enabled_alias() { - let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); - - assert_eq!(availability, PluginAvailability::Available); - assert_eq!( - serde_json::to_value(availability).unwrap(), - json!("AVAILABLE") - ); - } - - #[test] - fn plugin_uninstall_params_serialization_omits_force_remote_sync() { - assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - }) - .unwrap(), - json!({ - "pluginId": "gmail@openai-curated", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "pluginId": "gmail@openai-curated", - "forceRemoteSync": true, - })) - .unwrap(), - PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - }, - ); - - assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "plugins~Plugin_gmail".to_string(), - }) - .unwrap(), - json!({ - "pluginId": "plugins~Plugin_gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "pluginId": "plugins~Plugin_gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginUninstallParams { - plugin_id: "plugins~Plugin_gmail".to_string(), - }, - ); - } - - #[test] - fn marketplace_remove_response_serializes_nullable_installed_root() { - let installed_root = if cfg!(windows) { - r"C:\marketplaces\debug" - } else { - "/tmp/marketplaces/debug" - }; - let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap(); - let installed_root_json = installed_root.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(MarketplaceRemoveResponse { - marketplace_name: "debug".to_string(), - installed_root: Some(installed_root), - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - "installedRoot": installed_root_json, - }), - ); - - assert_eq!( - serde_json::to_value(MarketplaceRemoveResponse { - marketplace_name: "debug".to_string(), - installed_root: None, - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - "installedRoot": null, - }), - ); - } - - #[test] - fn marketplace_upgrade_response_serializes_camel_case_fields() { - let upgraded_root = if cfg!(windows) { - r"C:\marketplaces\debug" - } else { - "/tmp/marketplaces/debug" - }; - let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); - let upgraded_root_json = upgraded_root.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(MarketplaceUpgradeResponse { - selected_marketplaces: vec!["debug".to_string()], - upgraded_roots: vec![upgraded_root], - errors: vec![MarketplaceUpgradeErrorInfo { - marketplace_name: "broken".to_string(), - message: "failed to clone".to_string(), - }], - }) - .unwrap(), - json!({ - "selectedMarketplaces": ["debug"], - "upgradedRoots": [upgraded_root_json], - "errors": [{ - "marketplaceName": "broken", - "message": "failed to clone", - }], - }), - ); - } - - #[test] - fn codex_error_info_serializes_http_status_code_in_camel_case() { - let value = CodexErrorInfo::ResponseTooManyFailedAttempts { - http_status_code: Some(401), - }; - - assert_eq!( - serde_json::to_value(value).unwrap(), - json!({ - "responseTooManyFailedAttempts": { - "httpStatusCode": 401 - } - }) - ); - } - - #[test] - fn codex_error_info_serializes_cyber_policy_in_camel_case() { - assert_eq!( - serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), - json!("cyberPolicy") - ); - } - - #[test] - fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { - let value = CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, - }; - - assert_eq!( - serde_json::to_value(value).unwrap(), - json!({ - "activeTurnNotSteerable": { - "turnKind": "review" - } - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - } - ], - "success": true, - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_text_and_image_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![ - DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }, - DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - }, - ], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - }, - { - "type": "inputImage", - "imageUrl": "data:image/png;base64,AAA" - } - ], - "success": true, - }) - ); - } - - #[test] - fn dynamic_tool_spec_deserializes_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": "string" } - } - }, - "deferLoading": true, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert_eq!( - actual, - DynamicToolSpec { - namespace: None, - name: "lookup_ticket".to_string(), - description: "Fetch a ticket".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "id": { "type": "string" } - } - }), - defer_loading: true, - } - ); - } - - #[test] - fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": {} - }, - "exposeToContext": false, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert!(actual.defer_loading); - } - - #[test] - fn thread_start_params_preserve_explicit_null_service_tier() { - let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null })) - .expect("params should deserialize"); - assert_eq!(params.service_tier, Some(None)); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("serviceTier"), - Some(&serde_json::Value::Null) - ); - - let serialized_without_override = - serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); - assert_eq!(serialized_without_override.get("serviceTier"), None); - } - - #[test] - fn thread_lifecycle_responses_default_missing_compat_fields() { - let response = json!({ - "thread": { - "id": "thread-id", - "forkedFromId": null, - "preview": "", - "ephemeral": false, - "modelProvider": "openai", - "createdAt": 1, - "updatedAt": 1, - "status": { "type": "idle" }, - "path": null, - "cwd": absolute_path_string("tmp"), - "cliVersion": "0.0.0", - "source": "exec", - "agentNickname": null, - "agentRole": null, - "gitInfo": null, - "name": null, - "turns": [] - }, - "model": "gpt-5", - "modelProvider": "openai", - "serviceTier": null, - "cwd": absolute_path_string("tmp"), - "approvalPolicy": "on-failure", - "approvalsReviewer": "user", - "sandbox": { "type": "dangerFullAccess" }, - "reasoningEffort": null - }); - - let start: ThreadStartResponse = - serde_json::from_value(response.clone()).expect("thread/start response"); - let resume: ThreadResumeResponse = - serde_json::from_value(response.clone()).expect("thread/resume response"); - let fork: ThreadForkResponse = - serde_json::from_value(response).expect("thread/fork response"); - - assert_eq!(start.instruction_sources, Vec::::new()); - assert_eq!(resume.instruction_sources, Vec::::new()); - assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); - assert_eq!(start.active_permission_profile, None); - assert_eq!(resume.active_permission_profile, None); - assert_eq!(fork.active_permission_profile, None); - } - - #[test] - fn turn_start_params_preserve_explicit_null_service_tier() { - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "serviceTier": null - })) - .expect("params should deserialize"); - assert_eq!(params.service_tier, Some(None)); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("serviceTier"), - Some(&serde_json::Value::Null) - ); - - let without_override = TurnStartParams { - thread_id: "thread_123".to_string(), - input: vec![], - responsesapi_client_metadata: None, - environments: None, - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permissions: None, - model: None, - service_tier: None, - effort: None, - summary: None, - output_schema: None, - collaboration_mode: None, - personality: None, - }; - let serialized_without_override = - serde_json::to_value(&without_override).expect("params should serialize"); - assert_eq!(serialized_without_override.get("serviceTier"), None); - } - - #[test] - fn turn_start_params_round_trip_environments() { - let cwd = test_absolute_path(); - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": cwd - } - ], - })) - .expect("params should deserialize"); - - assert_eq!( - params.environments, - Some(vec![TurnEnvironmentParams { - environment_id: "local".to_string(), - cwd: cwd.clone(), - }]) - ); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), - Some("turn/start.environments") - ); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("environments"), - Some(&json!([ - { - "environmentId": "local", - "cwd": cwd - } - ])) - ); - } - - #[test] - fn turn_start_params_preserve_empty_environments() { - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": [], - })) - .expect("params should deserialize"); - - assert_eq!(params.environments, Some(Vec::new())); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), - Some("turn/start.environments") - ); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!(serialized.get("environments"), Some(&json!([]))); - } - - #[test] - fn turn_start_params_treat_null_or_omitted_environments_as_default() { - let null_environments: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": null, - })) - .expect("params should deserialize"); - let omitted_environments: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - })) - .expect("params should deserialize"); - - assert_eq!(null_environments.environments, None); - assert_eq!(omitted_environments.environments, None); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), - None - ); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), - None - ); - } - - #[test] - fn turn_start_params_reject_relative_environment_cwd() { - let err = serde_json::from_value::(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": "relative" - } - ], - })) - .expect_err("relative environment cwd should fail"); - - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); - } -} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs new file mode 100644 index 0000000000..efb4a26f60 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -0,0 +1,383 @@ +use crate::protocol::common::AuthMode; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::account::PlanType; +use codex_protocol::account::ProviderAccount; +use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum Account { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { email: String, plan_type: PlanType }, + + #[serde(rename = "amazonBedrock", rename_all = "camelCase")] + #[ts(rename = "amazonBedrock", rename_all = "camelCase")] + AmazonBedrock {}, +} + +impl From for Account { + fn from(account: ProviderAccount) -> Self { + match account { + ProviderAccount::ApiKey => Self::ApiKey {}, + ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, + ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(tag = "type")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountParams { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey { + #[serde(rename = "apiKey")] + #[ts(rename = "apiKey")] + api_key: String, + }, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + codex_streamlined_login: bool, + }, + #[serde(rename = "chatgptDeviceCode")] + #[ts(rename = "chatgptDeviceCode")] + ChatgptDeviceCode, + /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. + /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. + #[experimental("account/login/start.chatgptAuthTokens")] + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens { + /// Access token (JWT) supplied by the client. + /// This token is used for backend API requests and email extraction. + access_token: String, + /// Workspace/account identifier supplied by the client. + chatgpt_account_id: String, + /// Optional plan type supplied by the client. + /// + /// When `null`, Codex attempts to derive the plan type from access-token + /// claims. If unavailable, the plan defaults to `unknown`. + #[ts(optional = nullable)] + chatgpt_plan_type: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountResponse { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to initiate the OAuth flow. + auth_url: String, + }, + #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] + #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] + ChatgptDeviceCode { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to complete device code authorization. + verification_url: String, + /// One-time code the user must enter after signing in. + user_code: String, + }, + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountParams { + pub login_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CancelLoginAccountStatus { + Canceled, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse { + pub status: CancelLoginAccountStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct LogoutAccountResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ChatgptAuthTokensRefreshReason { + /// Codex attempted a backend request and received `401 Unauthorized`. + Unauthorized, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshParams { + pub reason: ChatgptAuthTokensRefreshReason, + /// Workspace/account identifier that Codex was previously using. + /// + /// Clients that manage multiple accounts/workspaces can use this as a hint + /// to refresh the token for the correct workspace. + /// + /// This may be `null` when the prior auth state did not include a workspace + /// identifier (`chatgpt_account_id`). + #[ts(optional = nullable)] + pub previous_account_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshResponse { + pub access_token: String, + pub chatgpt_account_id: String, + pub chatgpt_plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountRateLimitsResponse { + /// Backward-compatible single-bucket view; mirrors the historical payload. + pub rate_limits: RateLimitSnapshot, + /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). + pub rate_limits_by_limit_id: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailParams { + pub credit_type: AddCreditsNudgeCreditType, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailResponse { + pub status: AddCreditsNudgeEmailStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountParams { + /// When `true`, requests a proactive token refresh before returning. + /// + /// In managed auth mode this triggers the normal refresh-token flow. In + /// external auth mode this flag is ignored. Clients should refresh tokens + /// themselves and call `account/login/start` with `chatgptAuthTokens`. + #[serde(default)] + pub refresh_token: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountResponse { + pub account: Option, + pub requires_openai_auth: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountUpdatedNotification { + pub auth_mode: Option, + pub plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountRateLimitsUpdatedNotification { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitSnapshot { + pub limit_id: Option, + pub limit_name: Option, + pub primary: Option, + pub secondary: Option, + pub credits: Option, + pub plan_type: Option, + pub rate_limit_reached_type: Option, +} + +impl From for RateLimitSnapshot { + fn from(value: CoreRateLimitSnapshot) -> Self { + Self { + limit_id: value.limit_id, + limit_name: value.limit_name, + primary: value.primary.map(RateLimitWindow::from), + secondary: value.secondary.map(RateLimitWindow::from), + credits: value.credits.map(CreditsSnapshot::from), + plan_type: value.plan_type, + rate_limit_reached_type: value + .rate_limit_reached_type + .map(RateLimitReachedType::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, +} + +impl From for RateLimitReachedType { + fn from(value: CoreRateLimitReachedType) -> Self { + match value { + CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, + CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +impl From for CoreRateLimitReachedType { + fn from(value: RateLimitReachedType) -> Self { + match value { + RateLimitReachedType::RateLimitReached => Self::RateLimitReached, + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitWindow { + pub used_percent: i32, + #[ts(type = "number | null")] + pub window_duration_mins: Option, + #[ts(type = "number | null")] + pub resets_at: Option, +} + +impl From for RateLimitWindow { + fn from(value: CoreRateLimitWindow) -> Self { + Self { + used_percent: value.used_percent.round() as i32, + window_duration_mins: value.window_minutes, + resets_at: value.resets_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CreditsSnapshot { + pub has_credits: bool, + pub unlimited: bool, + pub balance: Option, +} + +impl From for CreditsSnapshot { + fn from(value: CoreCreditsSnapshot) -> Self { + Self { + has_credits: value.has_credits, + unlimited: value.unlimited, + balance: value.balance, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountLoginCompletedNotification { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + pub login_id: Option, + pub success: bool, + pub error: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs new file mode 100644 index 0000000000..9f46525e6c --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs @@ -0,0 +1,146 @@ +use super::shared::default_enabled; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - list available apps/connectors. +pub struct AppsListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional thread id used to evaluate app feature gating from that thread's config. + #[ts(optional = nullable)] + pub thread_id: Option, + /// When true, bypass app caches and fetch the latest data from sources. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_refetch: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppBranding { + pub category: Option, + pub developer: Option, + pub website: Option, + pub privacy_policy: Option, + pub terms_of_service: Option, + pub is_discoverable_app: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppReview { + pub status: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppScreenshot { + pub url: Option, + #[serde(alias = "file_id")] + pub file_id: Option, + #[serde(alias = "user_prompt")] + pub user_prompt: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppMetadata { + pub review: Option, + pub categories: Option>, + pub sub_categories: Option>, + pub seo_description: Option, + pub screenshots: Option>, + pub developer: Option, + pub version: Option, + pub version_id: Option, + pub version_notes: Option, + pub first_party_type: Option, + pub first_party_requires_install: Option, + pub show_in_composer_when_unlinked: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppInfo { + pub id: String, + pub name: String, + pub description: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, + pub install_url: Option, + #[serde(default)] + pub is_accessible: bool, + /// Whether this app is enabled in config.toml. + /// Example: + /// ```toml + /// [apps.bad_app] + /// enabled = false + /// ``` + #[serde(default = "default_enabled")] + pub is_enabled: bool, + #[serde(default)] + pub plugin_display_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata summary for plugin responses. +pub struct AppSummary { + pub id: String, + pub name: String, + pub description: Option, + pub install_url: Option, + pub needs_auth: bool, +} + +impl From for AppSummary { + fn from(value: AppInfo) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + install_url: value.install_url, + needs_auth: false, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app list response. +pub struct AppsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - notification emitted when the app list changes. +pub struct AppListUpdatedNotification { + pub data: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs b/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs new file mode 100644 index 0000000000..b013bc13d4 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs @@ -0,0 +1,45 @@ +use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ReasoningEffort; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// EXPERIMENTAL - list collaboration mode presets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListParams {} + +/// EXPERIMENTAL - collaboration mode preset metadata for clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeMask { + pub name: String, + pub mode: Option, + pub model: Option, + #[serde(rename = "reasoning_effort")] + #[ts(rename = "reasoning_effort")] + pub reasoning_effort: Option>, +} + +impl From for CollaborationModeMask { + fn from(value: CoreCollaborationModeMask) -> Self { + Self { + name: value.name, + mode: value.mode, + model: value.model, + reasoning_effort: value.reasoning_effort, + } + } +} + +/// EXPERIMENTAL - collaboration mode presets response. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListResponse { + pub data: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs new file mode 100644 index 0000000000..ff0cecf4f9 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -0,0 +1,214 @@ +use super::PermissionProfile; +use super::SandboxPolicy; +use codex_experimental_api_macros::ExperimentalApi; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +/// PTY size in character cells for `command/exec` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Run a standalone command (argv vector) in the server sandbox without +/// creating a thread or turn. +/// +/// The final `command/exec` response is deferred until the process exits and is +/// sent only after all `command/exec/outputDelta` notifications for that +/// connection have been emitted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Optional client-supplied, connection-scoped process id. + /// + /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + /// `command/exec/write`, `command/exec/resize`, and + /// `command/exec/terminate` calls. When omitted, buffered execution gets an + /// internal id that is not exposed to the client. + #[ts(optional = nullable)] + pub process_id: Option, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `command/exec/write` requests to write stdin bytes. + /// + /// Requires a client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `command/exec/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the final response and require a + /// client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableOutputCap`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option, + /// Disable stdout/stderr capture truncation for this request. + /// + /// Cannot be combined with `outputBytesCap`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_output_cap: bool, + /// Disable the timeout entirely for this request. + /// + /// Cannot be combined with `timeoutMs`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_timeout: bool, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableTimeout`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option, + /// Optional working directory. Defaults to the server cwd. + #[ts(optional = nullable)] + pub cwd: Option, + /// Optional environment overrides merged into the server-computed + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, + /// Optional sandbox policy for this command. + /// + /// Uses the same shape as thread/turn execution sandbox configuration and + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("command/exec.permissionProfile")] + #[ts(optional = nullable)] + pub permission_profile: Option, +} + +/// Final buffered result for `command/exec`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResponse { + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `command/exec/outputDelta`. + pub stdout: String, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `command/exec/outputDelta`. + pub stderr: String, +} + +/// Write stdin bytes to a running `command/exec` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `command/exec/write`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteResponse {} + +/// Terminate a running `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, +} + +/// Empty success response for `command/exec/terminate`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateResponse {} + +/// Resize a running PTY-backed `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// New PTY size in character cells. + pub size: CommandExecTerminalSize, +} + +/// Empty success response for `command/exec/resize`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeResponse {} + +/// Stream label for `command/exec/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} +/// Base64-encoded output chunk emitted for a streaming `command/exec` request. +/// +/// These notifications are connection-scoped. If the originating connection +/// closes, the server terminates the process. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecOutputDeltaNotification { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Output stream for this chunk. + pub stream: CommandExecOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// `true` on the final streamed chunk for a stream when `outputBytesCap` + /// truncated later output on that stream. + pub cap_reached: bool, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs new file mode 100644 index 0000000000..da4f493d77 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -0,0 +1,702 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::SandboxMode; +use super::shared::default_enabled; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ConfigLayerSource { + /// Managed preferences layer delivered by MDM (macOS only). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Mdm { + domain: String, + key: String, + }, + + /// Managed config layer from a file (usually `managed_config.toml`). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + System { + /// This is the path to the system config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// User config layer from $CODEX_HOME/config.toml. This layer is special + /// in that it is expected to be: + /// - writable by the user + /// - generally outside the workspace directory + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + User { + /// This is the path to the user's config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// Path to a .codex/ folder within a project. There could be multiple of + /// these between `cwd` and the project/repo root. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Project { + dot_codex_folder: AbsolutePathBuf, + }, + + /// Session-layer overrides supplied via `-c`/`--config`. + SessionFlags, + + /// `managed_config.toml` was designed to be a config that was loaded + /// as the last layer on top of everything else. This scheme did not quite + /// work out as intended, but we keep this variant as a "best effort" while + /// we phase out `managed_config.toml` in favor of `requirements.toml`. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf, + }, + + LegacyManagedConfigTomlFromMdm, +} + +impl ConfigLayerSource { + /// A settings from a layer with a higher precedence will override a setting + /// from a layer with a lower precedence. + pub fn precedence(&self) -> i16 { + match self { + ConfigLayerSource::Mdm { .. } => 0, + ConfigLayerSource::System { .. } => 10, + ConfigLayerSource::User { .. } => 20, + ConfigLayerSource::Project { .. } => 25, + ConfigLayerSource::SessionFlags => 30, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, + } + } +} + +/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from +/// layer `A` will be overridden by settings from layer `B`. +impl PartialOrd for ConfigLayerSource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.precedence().cmp(&other.precedence())) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional profile-level override for where approval requests + /// are routed for review. If omitted, the enclosing config default is + /// used. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub service_tier: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub web_search: Option, + pub tools: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AnalyticsConfig { + pub enabled: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_enabled")] + pub destructive_enabled: bool, + #[serde(default = "default_enabled")] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + pub destructive_enabled: Option, + pub open_world_enabled: Option, + pub default_tools_approval_mode: Option, + pub default_tools_enabled: Option, + pub tools: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsConfig { + #[serde(default, rename = "_default")] + pub default: Option, + #[serde(default, flatten)] + pub apps: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional default for where approval requests are routed for + /// review. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub web_search: Option, + pub tools: Option, + pub profile: Option, + #[experimental(nested)] + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub service_tier: Option, + pub analytics: Option, + #[experimental("config/read.apps")] + #[serde(default)] + pub apps: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayerMetadata { + pub name: ConfigLayerSource, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayer { + pub name: ConfigLayerSource, + pub version: String, + pub config: JsonValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum MergeStrategy { + Replace, + Upsert, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WriteStatus { + Ok, + OkOverridden, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct OverriddenMetadata { + pub message: String, + pub overriding_layer: ConfigLayerMetadata, + pub effective_value: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWriteResponse { + pub status: WriteStatus, + pub version: String, + /// Canonical path to the config file that was written. + pub file_path: AbsolutePathBuf, + pub overridden_metadata: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ConfigWriteErrorCode { + ConfigLayerReadonly, + ConfigVersionConflict, + ConfigValidationError, + ConfigPathNotFound, + ConfigSchemaUnknownKey, + UserLayerNotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadParams { + #[serde(default)] + pub include_layers: bool, + /// Optional working directory to resolve project config layers. If specified, + /// return the effective config as seen from that directory (i.e., including any + /// project layers between `cwd` and the project/repo root). + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadResponse { + #[experimental(nested)] + pub config: Config, + pub origins: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub layers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirements { + #[experimental(nested)] + pub allowed_approval_policies: Option>, + #[experimental("configRequirements/read.allowedApprovalsReviewers")] + pub allowed_approvals_reviewers: Option>, + pub allowed_sandbox_modes: Option>, + pub allowed_web_search_modes: Option>, + pub feature_requirements: Option>, + #[experimental("configRequirements/read.hooks")] + pub hooks: Option, + pub enforce_residency: Option, + #[experimental("configRequirements/read.network")] + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ManagedHooksRequirements { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(rename = "PreToolUse")] + #[ts(rename = "PreToolUse")] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest")] + #[ts(rename = "PermissionRequest")] + pub permission_request: Vec, + #[serde(rename = "PostToolUse")] + #[ts(rename = "PostToolUse")] + pub post_tool_use: Vec, + #[serde(rename = "SessionStart")] + #[ts(rename = "SessionStart")] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit")] + #[ts(rename = "UserPromptSubmit")] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop")] + #[ts(rename = "Stop")] + pub stop: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfiguredHookMatcherGroup { + pub matcher: Option, + pub hooks: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ConfiguredHookHandler { + #[serde(rename = "command")] + #[ts(rename = "command")] + Command { + command: String, + #[serde(rename = "timeoutSec")] + #[ts(rename = "timeoutSec")] + timeout_sec: Option, + r#async: bool, + #[serde(rename = "statusMessage")] + #[ts(rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + #[ts(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + #[ts(rename = "agent")] + Agent {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkRequirements { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + /// Canonical network permission map for `experimental_network`. + pub domains: Option>, + /// When true, only managed allowlist entries are respected while managed + /// network enforcement is active. + pub managed_allowed_domains_only: Option, + /// Legacy compatibility view derived from `domains`. + pub allowed_domains: Option>, + /// Legacy compatibility view derived from `domains`. + pub denied_domains: Option>, + /// Canonical unix socket permission map for `experimental_network`. + pub unix_sockets: Option>, + /// Legacy compatibility view derived from `unix_sockets`. + pub allow_unix_sockets: Option>, + pub allow_local_binding: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkDomainPermission { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ResidencyRequirement { + Us, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirementsReadResponse { + /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). + #[experimental(nested)] + pub requirements: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum ExternalAgentConfigMigrationItemType { + #[serde(rename = "AGENTS_MD")] + #[ts(rename = "AGENTS_MD")] + AgentsMd, + #[serde(rename = "CONFIG")] + #[ts(rename = "CONFIG")] + Config, + #[serde(rename = "SKILLS")] + #[ts(rename = "SKILLS")] + Skills, + #[serde(rename = "PLUGINS")] + #[ts(rename = "PLUGINS")] + Plugins, + #[serde(rename = "MCP_SERVER_CONFIG")] + #[ts(rename = "MCP_SERVER_CONFIG")] + McpServerConfig, + #[serde(rename = "SUBAGENTS")] + #[ts(rename = "SUBAGENTS")] + Subagents, + #[serde(rename = "HOOKS")] + #[ts(rename = "HOOKS")] + Hooks, + #[serde(rename = "COMMANDS")] + #[ts(rename = "COMMANDS")] + Commands, + #[serde(rename = "SESSIONS")] + #[ts(rename = "SESSIONS")] + Sessions, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginsMigration { + #[serde(rename = "marketplaceName")] + #[ts(rename = "marketplaceName")] + pub marketplace_name: String, + #[serde(rename = "pluginNames")] + #[ts(rename = "pluginNames")] + pub plugin_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SessionMigration { + pub path: PathBuf, + pub cwd: PathBuf, + pub title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SubagentMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MigrationDetails { + #[serde(default)] + pub plugins: Vec, + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub mcp_servers: Vec, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub subagents: Vec, + #[serde(default)] + pub commands: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigMigrationItem { + pub item_type: ExternalAgentConfigMigrationItemType, + pub description: String, + /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. + pub cwd: Option, + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectResponse { + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectParams { + /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_home: bool, + /// Zero or more working directories to include for repo-scoped detection. + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportParams { + pub migration_items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportCompletedNotification {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigValueWriteParams { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigBatchWriteParams { + pub edits: Vec, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, + /// When true, hot-reload the updated user config into all loaded threads after writing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reload_user_config: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigEdit { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextPosition { + /// 1-based line number. + pub line: usize, + /// 1-based column number (in Unicode scalar values). + pub column: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWarningNotification { + /// Concise summary of the warning. + pub summary: String, + /// Optional extra guidance or error details. + pub details: Option, + /// Optional path to the config file that triggered the warning. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub path: Option, + /// Optional range for the error location inside the config file. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub range: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/device_key.rs b/codex-rs/app-server-protocol/src/protocol/v2/device_key.rs new file mode 100644 index 0000000000..3330996c1c --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/device_key.rs @@ -0,0 +1,181 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Device-key algorithm reported at enrollment and signing boundaries. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum DeviceKeyAlgorithm { + EcdsaP256Sha256, +} + +/// Platform protection class for a controller-local device key. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum DeviceKeyProtectionClass { + HardwareSecureEnclave, + HardwareTpm, + OsProtectedNonextractable, +} + +/// Protection policy for creating or loading a controller-local device key. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum DeviceKeyProtectionPolicy { + HardwareOnly, + AllowOsProtectedNonextractable, +} + +/// Create a controller-local device key with a random key id. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyCreateParams { + /// Defaults to `hardware_only` when omitted. + #[ts(optional = nullable)] + pub protection_policy: Option, + pub account_user_id: String, + pub client_id: String, +} + +/// Device-key metadata and public key returned by create/public APIs. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyCreateResponse { + pub key_id: String, + /// SubjectPublicKeyInfo DER encoded as base64. + pub public_key_spki_der_base64: String, + pub algorithm: DeviceKeyAlgorithm, + pub protection_class: DeviceKeyProtectionClass, +} + +/// Fetch a controller-local device key public key by id. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyPublicParams { + pub key_id: String, +} + +/// Device-key public metadata returned by `device/key/public`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyPublicResponse { + pub key_id: String, + /// SubjectPublicKeyInfo DER encoded as base64. + pub public_key_spki_der_base64: String, + pub algorithm: DeviceKeyAlgorithm, + pub protection_class: DeviceKeyProtectionClass, +} + +/// Current remote-control connection status and environment id exposed to clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlStatusChangedNotification { + pub status: RemoteControlConnectionStatus, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum RemoteControlConnectionStatus { + Disabled, + Connecting, + Connected, + Errored, +} + +/// Audience for a remote-control client connection device-key proof. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum RemoteControlClientConnectionAudience { + RemoteControlClientWebsocket, +} + +/// Audience for a remote-control client enrollment device-key proof. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum RemoteControlClientEnrollmentAudience { + RemoteControlClientEnrollment, +} + +/// Structured payloads accepted by `device/key/sign`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum DeviceKeySignPayload { + /// Payload bound to one remote-control controller websocket `/client` connection challenge. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RemoteControlClientConnection { + nonce: String, + audience: RemoteControlClientConnectionAudience, + /// Backend-issued websocket session id that this proof authorizes. + session_id: String, + /// Origin of the backend endpoint that issued the challenge and will verify this proof. + target_origin: String, + /// Websocket route path that this proof authorizes. + target_path: String, + account_user_id: String, + client_id: String, + /// Remote-control token expiration as Unix seconds. + #[ts(type = "number")] + token_expires_at: i64, + /// SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url. + token_sha256_base64url: String, + /// Must contain exactly `remote_control_controller_websocket`. + scopes: Vec, + }, + /// Payload bound to a remote-control client `/client/enroll` ownership challenge. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RemoteControlClientEnrollment { + nonce: String, + audience: RemoteControlClientEnrollmentAudience, + /// Backend-issued enrollment challenge id that this proof authorizes. + challenge_id: String, + /// Origin of the backend endpoint that issued the challenge and will verify this proof. + target_origin: String, + /// HTTP route path that this proof authorizes. + target_path: String, + account_user_id: String, + client_id: String, + /// SHA-256 of the requested device identity operation, encoded as unpadded base64url. + device_identity_sha256_base64url: String, + /// Enrollment challenge expiration as Unix seconds. + #[ts(type = "number")] + challenge_expires_at: i64, + }, +} + +/// Sign an accepted structured payload with a controller-local device key. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeySignParams { + pub key_id: String, + pub payload: DeviceKeySignPayload, +} + +/// ASN.1 DER signature returned by `device/key/sign`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeySignResponse { + /// ECDSA signature DER encoded as base64. + pub signature_der_base64: String, + /// Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte + /// string directly and must not reserialize `payload`. + pub signed_payload_base64: String, + pub algorithm: DeviceKeyAlgorithm, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs new file mode 100644 index 0000000000..6adc21b6ef --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs @@ -0,0 +1,85 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ExperimentalFeatureStage { + /// Feature is available for user testing and feedback. + Beta, + /// Feature is still being built and not ready for broad use. + UnderDevelopment, + /// Feature is production-ready. + Stable, + /// Feature is deprecated and should be avoided. + Deprecated, + /// Feature flag is retained only for backwards compatibility. + Removed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeature { + /// Stable key used in config.toml and CLI flag toggles. + pub name: String, + /// Lifecycle stage of this feature flag. + pub stage: ExperimentalFeatureStage, + /// User-facing display name shown in the experimental features UI. + /// Null when this feature is not in beta. + pub display_name: Option, + /// Short summary describing what the feature does. + /// Null when this feature is not in beta. + pub description: Option, + /// Announcement copy shown to users when the feature is introduced. + /// Null when this feature is not in beta. + pub announcement: Option, + /// Whether this feature is currently enabled in the loaded config. + pub enabled: bool, + /// Whether this feature is enabled by default. + pub default_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: BTreeMap, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs new file mode 100644 index 0000000000..aaf966a4bf --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs @@ -0,0 +1,29 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadParams { + pub classification: String, + #[ts(optional = nullable)] + pub reason: Option, + #[ts(optional = nullable)] + pub thread_id: Option, + pub include_logs: bool, + #[ts(optional = nullable)] + pub extra_log_files: Option>, + #[ts(optional = nullable)] + pub tags: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadResponse { + pub thread_id: String, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/fs.rs b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs new file mode 100644 index 0000000000..0132c6b284 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Read a file from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileParams { + /// Absolute path to read. + pub path: AbsolutePathBuf, +} + +/// Base64-encoded file contents returned by `fs/readFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileResponse { + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Write a file on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileParams { + /// Absolute path to write. + pub path: AbsolutePathBuf, + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Successful response for `fs/writeFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileResponse {} + +/// Create a directory on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryParams { + /// Absolute directory path to create. + pub path: AbsolutePathBuf, + /// Whether parent directories should also be created. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, +} + +/// Successful response for `fs/createDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryResponse {} + +/// Request metadata for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataParams { + /// Absolute path to inspect. + pub path: AbsolutePathBuf, +} + +/// Metadata returned by `fs/getMetadata`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataResponse { + /// Whether the path resolves to a directory. + pub is_directory: bool, + /// Whether the path resolves to a regular file. + pub is_file: bool, + /// Whether the path itself is a symbolic link. + pub is_symlink: bool, + /// File creation time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub created_at_ms: i64, + /// File modification time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub modified_at_ms: i64, +} + +/// List direct child names for a directory. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryParams { + /// Absolute directory path to read. + pub path: AbsolutePathBuf, +} + +/// A directory entry returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryEntry { + /// Direct child entry name only, not an absolute or relative path. + pub file_name: String, + /// Whether this entry resolves to a directory. + pub is_directory: bool, + /// Whether this entry resolves to a regular file. + pub is_file: bool, +} + +/// Directory entries returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryResponse { + /// Direct child entries in the requested directory. + pub entries: Vec, +} + +/// Remove a file or directory tree from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveParams { + /// Absolute path to remove. + pub path: AbsolutePathBuf, + /// Whether directory removal should recurse. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, + /// Whether missing paths should be ignored. Defaults to `true`. + #[ts(optional = nullable)] + pub force: Option, +} + +/// Successful response for `fs/remove`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveResponse {} + +/// Copy a file or directory tree on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyParams { + /// Absolute source path. + pub source_path: AbsolutePathBuf, + /// Absolute destination path. + pub destination_path: AbsolutePathBuf, + /// Required for directory copies; ignored for file copies. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recursive: bool, +} + +/// Successful response for `fs/copy`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyResponse {} + +/// Start filesystem watch notifications for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchParams { + /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + pub watch_id: String, + /// Absolute file or directory path to watch. + pub path: AbsolutePathBuf, +} + +/// Successful response for `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchResponse { + /// Canonicalized path associated with the watch. + pub path: AbsolutePathBuf, +} + +/// Stop filesystem watch notifications for a prior `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchParams { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, +} + +/// Successful response for `fs/unwatch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchResponse {} + +/// Filesystem watch notification emitted for `fs/watch` subscribers. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsChangedNotification { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, + /// File or directory paths associated with this event. + pub changed_paths: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs new file mode 100644 index 0000000000..e0f74b5ded --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -0,0 +1,154 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::protocol::HookEventName as CoreHookEventName; +use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode; +use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType; +use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; +use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; +use codex_protocol::protocol::HookScope as CoreHookScope; +use codex_protocol::protocol::HookSource as CoreHookSource; +use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum HookEventName from CoreHookEventName { + PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop + } +); + +v2_enum_from_core!( + pub enum HookHandlerType from CoreHookHandlerType { + Command, Prompt, Agent + } +); + +v2_enum_from_core!( + pub enum HookExecutionMode from CoreHookExecutionMode { + Sync, Async + } +); + +v2_enum_from_core!( + pub enum HookScope from CoreHookScope { + Thread, Turn + } +); + +v2_enum_from_core!( + pub enum HookSource from CoreHookSource { + System, + User, + Project, + Mdm, + SessionFlags, + Plugin, + CloudRequirements, + LegacyManagedConfigFile, + LegacyManagedConfigMdm, + Unknown, + } +); + +v2_enum_from_core!( + pub enum HookTrustStatus from CoreHookTrustStatus { + Managed, Untrusted, Trusted, Modified + } +); + +fn default_hook_source() -> HookSource { + HookSource::Unknown +} + +v2_enum_from_core!( + pub enum HookRunStatus from CoreHookRunStatus { + Running, Completed, Failed, Blocked, Stopped + } +); + +v2_enum_from_core!( + pub enum HookOutputEntryKind from CoreHookOutputEntryKind { + Warning, Stop, Feedback, Context, Error + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookOutputEntry { + pub kind: HookOutputEntryKind, + pub text: String, +} + +impl From for HookOutputEntry { + fn from(value: CoreHookOutputEntry) -> Self { + Self { + kind: value.kind.into(), + text: value.text, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookRunSummary { + pub id: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub execution_mode: HookExecutionMode, + pub scope: HookScope, + pub source_path: AbsolutePathBuf, + #[serde(default = "default_hook_source")] + pub source: HookSource, + pub display_order: i64, + pub status: HookRunStatus, + pub status_message: Option, + pub started_at: i64, + pub completed_at: Option, + pub duration_ms: Option, + pub entries: Vec, +} + +impl From for HookRunSummary { + fn from(value: CoreHookRunSummary) -> Self { + Self { + id: value.id, + event_name: value.event_name.into(), + handler_type: value.handler_type.into(), + execution_mode: value.execution_mode.into(), + scope: value.scope.into(), + source_path: value.source_path, + source: value.source.into(), + display_order: value.display_order, + status: value.status.into(), + status_message: value.status_message, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, + entries: value.entries.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookStartedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookCompletedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs new file mode 100644 index 0000000000..2c3a926c91 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -0,0 +1,1432 @@ +use super::AdditionalPermissionProfile; +use super::ExecPolicyAmendment; +use super::McpToolCallError; +use super::McpToolCallResult; +use super::NetworkApprovalContext; +use super::NetworkApprovalProtocol; +use super::NetworkPolicyAmendment; +use super::RequestPermissionProfile; +use super::UserInput; +use super::shared::v2_enum_from_core; +use crate::protocol::item_builders::convert_patch_changes; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; +use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; +use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; +use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::TurnItem as CoreTurnItem; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; +use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use serde_with::serde_as; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionApprovalDecision { + /// User approved the command. + Accept, + /// User approved the command and future prompts in the same session-scoped + /// approval cache should run without prompting. + AcceptForSession, + /// User approved the command, and wants to apply the proposed execpolicy amendment so future + /// matching commands can run without prompting. + AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment, + }, + /// User chose a persistent network policy rule (allow/deny) for this host. + ApplyNetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment, + }, + /// User denied the command. The agent will continue the turn. + Decline, + /// User denied the command. The turn will also be immediately interrupted. + Cancel, +} + +impl From for CommandExecutionApprovalDecision { + fn from(value: CoreReviewDecision) -> Self { + match value { + CoreReviewDecision::Approved => Self::Accept, + CoreReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } => Self::AcceptWithExecpolicyAmendment { + execpolicy_amendment: proposed_execpolicy_amendment.into(), + }, + CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, + CoreReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => Self::ApplyNetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into(), + }, + CoreReviewDecision::Abort => Self::Cancel, + CoreReviewDecision::Denied => Self::Decline, + CoreReviewDecision::TimedOut => Self::Decline, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum FileChangeApprovalDecision { + /// User approved the file changes. + Accept, + /// User approved the file changes and future changes to the same files should run without prompting. + AcceptForSession, + /// User denied the file changes. The agent will continue the turn. + Decline, + /// User denied the file changes. The turn will also be immediately interrupted. + Cancel, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum CommandAction { + Read { + command: String, + name: String, + path: AbsolutePathBuf, + }, + ListFiles { + command: String, + path: Option, + }, + Search { + command: String, + query: Option, + path: Option, + }, + Unknown { + command: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitation { + pub entries: Vec, + pub thread_ids: Vec, +} + +impl From for MemoryCitation { + fn from(value: CoreMemoryCitation) -> Self { + Self { + entries: value.entries.into_iter().map(Into::into).collect(), + thread_ids: value.rollout_ids, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitationEntry { + pub path: String, + pub line_start: u32, + pub line_end: u32, + pub note: String, +} + +impl From for MemoryCitationEntry { + fn from(value: CoreMemoryCitationEntry) -> Self { + Self { + path: value.path, + line_start: value.line_start, + line_end: value.line_end, + note: value.note, + } + } +} + +impl CommandAction { + pub fn into_core(self) -> CoreParsedCommand { + match self { + CommandAction::Read { + command: cmd, + name, + path, + } => CoreParsedCommand::Read { + cmd, + name, + path: path.into_path_buf(), + }, + CommandAction::ListFiles { command: cmd, path } => { + CoreParsedCommand::ListFiles { cmd, path } + } + CommandAction::Search { + command: cmd, + query, + path, + } => CoreParsedCommand::Search { cmd, query, path }, + CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, + } + } + + pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { + match value { + CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { + command: cmd, + name, + path: cwd.join(path), + }, + CoreParsedCommand::ListFiles { cmd, path } => { + CommandAction::ListFiles { command: cmd, path } + } + CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { + command: cmd, + query, + path, + }, + CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadItem { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + UserMessage { id: String, content: Vec }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + HookPrompt { + id: String, + fragments: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AgentMessage { + id: String, + text: String, + #[serde(default)] + phase: Option, + #[serde(default)] + memory_citation: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + /// EXPERIMENTAL - proposed plan item content. The completed plan item is + /// authoritative and may not match the concatenation of `PlanDelta` text. + Plan { id: String, text: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Reasoning { + id: String, + #[serde(default)] + summary: Vec, + #[serde(default)] + content: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CommandExecution { + id: String, + /// The command to be executed. + command: String, + /// The command's working directory. + cwd: AbsolutePathBuf, + /// Identifier for the underlying PTY process (when available). + process_id: Option, + #[serde(default)] + source: CommandExecutionSource, + status: CommandExecutionStatus, + /// 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. + command_actions: Vec, + /// The command's output, aggregated from stdout and stderr. + aggregated_output: Option, + /// The command's exit code. + exit_code: Option, + /// The duration of the command execution in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + FileChange { + id: String, + changes: Vec, + status: PatchApplyStatus, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + id: String, + server: String, + tool: String, + status: McpToolCallStatus, + arguments: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + mcp_app_resource_uri: Option, + result: Option>, + error: Option, + /// The duration of the MCP tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + DynamicToolCall { + id: String, + namespace: Option, + tool: String, + arguments: JsonValue, + status: DynamicToolCallStatus, + content_items: Option>, + success: Option, + /// The duration of the dynamic tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CollabAgentToolCall { + /// Unique identifier for this collab tool call. + id: String, + /// Name of the collab tool that was invoked. + tool: CollabAgentTool, + /// Current status of the collab tool call. + status: CollabAgentToolCallStatus, + /// Thread ID of the agent issuing the collab request. + sender_thread_id: String, + /// Thread ID of the receiving agent, when applicable. In case of spawn operation, + /// this corresponds to the newly spawned agent. + receiver_thread_ids: Vec, + /// Prompt text sent as part of the collab tool call, when available. + prompt: Option, + /// Model requested for the spawned agent, when applicable. + model: Option, + /// Reasoning effort requested for the spawned agent, when applicable. + reasoning_effort: Option, + /// Last known status of the target agents, when available. + agents_states: HashMap, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WebSearch { + id: String, + query: String, + action: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageView { id: String, path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageGeneration { + id: String, + status: String, + revised_prompt: Option, + result: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + saved_path: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + EnteredReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExitedReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ContextCompaction { id: String }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_id: String, +} + +impl ThreadItem { + pub fn id(&self) -> &str { + match self { + ThreadItem::UserMessage { id, .. } + | ThreadItem::HookPrompt { id, .. } + | ThreadItem::AgentMessage { id, .. } + | ThreadItem::Plan { id, .. } + | ThreadItem::Reasoning { id, .. } + | ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageView { id, .. } + | ThreadItem::ImageGeneration { id, .. } + | ThreadItem::EnteredReviewMode { id, .. } + | ThreadItem::ExitedReviewMode { id, .. } + | ThreadItem::ContextCompaction { id, .. } => id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Lifecycle state for an approval auto-review. +pub enum GuardianApprovalReviewStatus { + InProgress, + Approved, + Denied, + TimedOut, + Aborted, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Source that produced a terminal approval auto-review decision. +pub enum AutoReviewDecisionSource { + Agent, +} + +impl From for AutoReviewDecisionSource { + fn from(value: CoreGuardianAssessmentDecisionSource) -> Self { + match value { + CoreGuardianAssessmentDecisionSource::Agent => Self::Agent, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Risk level assigned by approval auto-review. +pub enum GuardianRiskLevel { + Low, + Medium, + High, + Critical, +} + +impl From for GuardianRiskLevel { + fn from(value: CoreGuardianRiskLevel) -> Self { + match value { + CoreGuardianRiskLevel::Low => Self::Low, + CoreGuardianRiskLevel::Medium => Self::Medium, + CoreGuardianRiskLevel::High => Self::High, + CoreGuardianRiskLevel::Critical => Self::Critical, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Authorization level assigned by approval auto-review. +pub enum GuardianUserAuthorization { + Unknown, + Low, + Medium, + High, +} + +impl From for GuardianUserAuthorization { + fn from(value: CoreGuardianUserAuthorization) -> Self { + match value { + CoreGuardianUserAuthorization::Unknown => Self::Unknown, + CoreGuardianUserAuthorization::Low => Self::Low, + CoreGuardianUserAuthorization::Medium => Self::Medium, + CoreGuardianUserAuthorization::High => Self::High, + } + } +} + +/// [UNSTABLE] Temporary approval auto-review payload used by +/// `item/autoApprovalReview/*` notifications. This shape is expected to change +/// soon. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApprovalReview { + pub status: GuardianApprovalReviewStatus, + pub risk_level: Option, + pub user_authorization: Option, + pub rationale: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianCommandSource { + Shell, + UnifiedExec, +} + +impl From for GuardianCommandSource { + fn from(value: CoreGuardianCommandSource) -> Self { + match value { + CoreGuardianCommandSource::Shell => Self::Shell, + CoreGuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +impl From for CoreGuardianCommandSource { + fn from(value: GuardianCommandSource) -> Self { + match value { + GuardianCommandSource::Shell => Self::Shell, + GuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianCommandReviewAction { + pub source: GuardianCommandSource, + pub command: String, + pub cwd: AbsolutePathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianExecveReviewAction { + pub source: GuardianCommandSource, + pub program: String, + pub argv: Vec, + pub cwd: AbsolutePathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApplyPatchReviewAction { + pub cwd: AbsolutePathBuf, + pub files: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianNetworkAccessReviewAction { + pub target: String, + pub host: String, + pub protocol: NetworkApprovalProtocol, + pub port: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianMcpToolCallReviewAction { + pub server: String, + pub tool_name: String, + pub connector_id: Option, + pub connector_name: Option, + pub tool_title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianRequestPermissionsReviewAction { + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianApprovalReviewAction { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Command { + source: GuardianCommandSource, + command: String, + cwd: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Execve { + source: GuardianCommandSource, + program: String, + argv: Vec, + cwd: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ApplyPatch { + cwd: AbsolutePathBuf, + files: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + NetworkAccess { + target: String, + host: String, + protocol: NetworkApprovalProtocol, + port: u16, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + server: String, + tool_name: String, + connector_id: Option, + connector_name: Option, + tool_title: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RequestPermissions { + reason: Option, + permissions: RequestPermissionProfile, + }, +} + +impl From for GuardianApprovalReviewAction { + fn from(value: CoreGuardianAssessmentAction) -> Self { + match value { + CoreGuardianAssessmentAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + CoreGuardianAssessmentAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + CoreGuardianAssessmentAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + CoreGuardianAssessmentAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.into(), + port, + }, + CoreGuardianAssessmentAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + CoreGuardianAssessmentAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +impl From for CoreGuardianAssessmentAction { + fn from(value: GuardianApprovalReviewAction) -> Self { + match value { + GuardianApprovalReviewAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + GuardianApprovalReviewAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + GuardianApprovalReviewAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + GuardianApprovalReviewAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.to_core(), + port, + }, + GuardianApprovalReviewAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + GuardianApprovalReviewAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WebSearchAction { + Search { + query: Option, + queries: Option>, + }, + OpenPage { + url: Option, + }, + FindInPage { + url: Option, + pattern: Option, + }, + #[serde(other)] + Other, +} + +impl From for WebSearchAction { + fn from(value: codex_protocol::models::WebSearchAction) -> Self { + match value { + codex_protocol::models::WebSearchAction::Search { query, queries } => { + WebSearchAction::Search { query, queries } + } + codex_protocol::models::WebSearchAction::OpenPage { url } => { + WebSearchAction::OpenPage { url } + } + codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { + WebSearchAction::FindInPage { url, pattern } + } + codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, + } + } +} + +impl From for ThreadItem { + fn from(value: CoreTurnItem) -> Self { + match value { + CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { + id: user.id, + content: user.content.into_iter().map(UserInput::from).collect(), + }, + CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(HookPromptFragment::from) + .collect(), + }, + CoreTurnItem::AgentMessage(agent) => { + let text = agent + .content + .into_iter() + .map(|entry| match entry { + CoreAgentMessageContent::Text { text } => text, + }) + .collect::(); + ThreadItem::AgentMessage { + id: agent.id, + text, + phase: agent.phase, + memory_citation: agent.memory_citation.map(Into::into), + } + } + CoreTurnItem::Plan(plan) => ThreadItem::Plan { + id: plan.id, + text: plan.text, + }, + CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { + id: reasoning.id, + summary: reasoning.summary_text, + content: reasoning.raw_content, + }, + CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { + id: search.id, + query: search.query, + action: Some(WebSearchAction::from(search.action)), + }, + CoreTurnItem::ImageView(image) => ThreadItem::ImageView { + id: image.id, + path: image.path, + }, + CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { + id: image.id, + status: image.status, + revised_prompt: image.revised_prompt, + result: image.result, + saved_path: image.saved_path, + }, + CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { + id: file_change.id, + changes: convert_patch_changes(&file_change.changes), + status: file_change + .status + .as_ref() + .map(PatchApplyStatus::from) + .unwrap_or(PatchApplyStatus::InProgress), + }, + CoreTurnItem::McpToolCall(mcp) => { + let duration_ms = mcp + .duration + .and_then(|duration| i64::try_from(duration.as_millis()).ok()); + + ThreadItem::McpToolCall { + id: mcp.id, + server: mcp.server, + tool: mcp.tool, + status: McpToolCallStatus::from(mcp.status), + arguments: mcp.arguments, + mcp_app_resource_uri: mcp.mcp_app_resource_uri, + result: mcp.result.map(McpToolCallResult::from).map(Box::new), + error: mcp.error.map(McpToolCallError::from), + duration_ms, + } + } + CoreTurnItem::ContextCompaction(compaction) => { + ThreadItem::ContextCompaction { id: compaction.id } + } + } + } +} + +impl From for HookPromptFragment { + fn from(value: codex_protocol::items::HookPromptFragment) -> Self { + Self { + text: value.text, + hook_run_id: value.hook_run_id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for CommandExecutionStatus { + fn from(value: CoreExecCommandStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CoreExecCommandStatus> for CommandExecutionStatus { + fn from(value: &CoreExecCommandStatus) -> Self { + match value { + CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed, + CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed, + CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined, + } + } +} + +v2_enum_from_core! { + #[derive(Default)] + pub enum CommandExecutionSource from CoreExecCommandSource { + #[default] + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentTool { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileUpdateChange { + pub path: String, + pub kind: PatchChangeKind, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PatchChangeKind { + Add, + Delete, + Update { move_path: Option }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchApplyStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for PatchApplyStatus { + fn from(value: CorePatchApplyStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CorePatchApplyStatus> for PatchApplyStatus { + fn from(value: &CorePatchApplyStatus) -> Self { + match value { + CorePatchApplyStatus::Completed => PatchApplyStatus::Completed, + CorePatchApplyStatus::Failed => PatchApplyStatus::Failed, + CorePatchApplyStatus::Declined => PatchApplyStatus::Declined, + } + } +} + +impl From for McpToolCallStatus { + fn from(value: CoreMcpToolCallStatus) -> Self { + match value { + CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress, + CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed, + CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentStatus { + PendingInit, + Running, + Interrupted, + Completed, + Errored, + Shutdown, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollabAgentState { + pub status: CollabAgentStatus, + pub message: Option, +} + +impl From for CollabAgentState { + fn from(value: CoreAgentStatus) -> Self { + match value { + CoreAgentStatus::PendingInit => Self { + status: CollabAgentStatus::PendingInit, + message: None, + }, + CoreAgentStatus::Running => Self { + status: CollabAgentStatus::Running, + message: None, + }, + CoreAgentStatus::Interrupted => Self { + status: CollabAgentStatus::Interrupted, + message: None, + }, + CoreAgentStatus::Completed(message) => Self { + status: CollabAgentStatus::Completed, + message, + }, + CoreAgentStatus::Errored(message) => Self { + status: CollabAgentStatus::Errored, + message: Some(message), + }, + CoreAgentStatus::Shutdown => Self { + status: CollabAgentStatus::Shutdown, + message: None, + }, + CoreAgentStatus::NotFound => Self { + status: CollabAgentStatus::NotFound, + message: None, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemStartedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle started. + #[ts(type = "number")] + pub started_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewStartedNotification { + pub thread_id: String, + pub turn_id: String, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewCompletedNotification { + pub thread_id: String, + pub turn_id: String, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub decision_source: AutoReviewDecisionSource, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemCompletedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle completed. + #[ts(type = "number")] + pub completed_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RawResponseItemCompletedNotification { + pub thread_id: String, + pub turn_id: String, + pub item: ResponseItem, +} + +// Item-specific progress notifications +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AgentMessageDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should +/// not assume concatenated deltas match the completed plan item content. +pub struct PlanDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryPartAddedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub content_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} +/// Deprecated legacy notification for `apply_patch` textual output. +/// +/// The server no longer emits this notification. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangePatchUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub changes: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unique identifier for this specific approval callback. + /// + /// For regular shell/unified_exec approvals, this is null. + /// + /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to + /// one parent `itemId`, so `approvalId` is a distinct opaque callback id + /// (a UUID) used to disambiguate routing. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub approval_id: Option, + /// Optional explanatory reason (e.g. request for network access). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub reason: Option, + /// Optional context for a managed-network approval prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub network_approval_context: Option, + /// The command to be executed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command: Option, + /// The command's working directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub cwd: Option, + /// Best-effort parsed command actions for friendly display. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command_actions: Option>, + /// Optional additional permissions requested for this command. + #[experimental("item/commandExecution/requestApproval.additionalPermissions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub additional_permissions: Option, + /// Optional proposed execpolicy amendment to allow similar commands without prompting. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_execpolicy_amendment: Option, + /// Optional proposed network policy amendments (allow/deny host) for future requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_network_policy_amendments: Option>, + /// Ordered list of decisions the client may present for this prompt. + #[experimental("item/commandExecution/requestApproval.availableDecisions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub available_decisions: Option>, +} + +impl CommandExecutionRequestApprovalParams { + pub fn strip_experimental_fields(&mut self) { + // TODO: Avoid hardcoding individual experimental fields here. + // We need a generic outbound compatibility design for stripping or + // otherwise handling experimental server->client payloads. + self.additional_permissions = None; + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalResponse { + pub decision: CommandExecutionApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Optional explanatory reason (e.g. request for extra write access). + #[ts(optional = nullable)] + pub reason: Option, + /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root + /// for the remainder of the session (unclear if this is honored today). + #[ts(optional = nullable)] + pub grant_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalResponse { + pub decision: FileChangeApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallParams { + pub thread_id: String, + pub turn_id: String, + pub call_id: String, + pub namespace: Option, + pub tool: String, + pub arguments: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallResponse { + pub content_items: Vec, + pub success: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallOutputContentItem { + #[serde(rename_all = "camelCase")] + InputText { text: String }, + #[serde(rename_all = "camelCase")] + InputImage { image_url: String }, +} + +impl From + for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem +{ + fn from(item: DynamicToolCallOutputContentItem) -> Self { + match item { + DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, + DynamicToolCallOutputContentItem::InputImage { image_url } => { + Self::InputImage { image_url } + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_user_input. +pub struct ToolRequestUserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Represents one request_user_input question and its required options. +pub struct ToolRequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(default)] + pub is_other: bool, + #[serde(default)] + pub is_secret: bool, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_user_input event. +pub struct ToolRequestUserInputParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +pub struct ToolRequestUserInputAnswer { + pub answers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload mapping question ids to answers. +pub struct ToolRequestUserInputResponse { + pub answers: HashMap, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs new file mode 100644 index 0000000000..9fd9384076 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -0,0 +1,703 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; +use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::Resource as McpResource; +pub use codex_protocol::mcp::ResourceContent as McpResourceContent; +use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; +use codex_protocol::mcp::Tool as McpTool; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a server-defined value. + #[ts(optional = nullable)] + pub limit: Option, + /// Controls how much MCP inventory data to fetch for each server. + /// Defaults to `Full` when omitted. + #[ts(optional = nullable)] + pub detail: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum McpServerStatusDetail { + Full, + ToolsAndAuthOnly, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatus { + pub name: String, + pub tools: std::collections::HashMap, + pub resources: Vec, + pub resource_templates: Vec, + pub auth_status: McpAuthStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadParams { + #[ts(optional = nullable)] + pub thread_id: Option, + pub server: String, + pub uri: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadResponse { + pub contents: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallParams { + pub thread_id: String, + pub server: String, + pub tool: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub arguments: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallResponse { + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub structured_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub is_error: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallResult { + // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust + // representation of MCP content blocks. We intentionally use `serde_json::Value` here because + // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types + // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust + // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. + pub content: Vec, + pub structured_content: Option, + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallError { + pub message: String, +} + +impl From for McpServerToolCallResponse { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + is_error: result.is_error, + meta: result.meta, + } + } +} + +impl From for McpToolCallResult { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + meta: result.meta, + } + } +} + +impl From for McpToolCallError { + fn from(error: CoreMcpToolCallError) -> Self { + Self { + message: error.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallProgressNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerStartupState { + Starting, + Ready, + Failed, + Cancelled, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatusUpdatedNotification { + pub name: String, + pub status: McpServerStartupState, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationAction { + Accept, + Decline, + Cancel, +} + +impl McpServerElicitationAction { + pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { + match self { + Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, + Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, + Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, + } + } +} + +impl From for rmcp::model::ElicitationAction { + fn from(value: McpServerElicitationAction) -> Self { + match value { + McpServerElicitationAction::Accept => Self::Accept, + McpServerElicitationAction::Decline => Self::Decline, + McpServerElicitationAction::Cancel => Self::Cancel, + } + } +} + +impl From for McpServerElicitationAction { + fn from(value: rmcp::model::ElicitationAction) -> Self { + match value { + rmcp::model::ElicitationAction::Accept => Self::Accept, + rmcp::model::ElicitationAction::Decline => Self::Decline, + rmcp::model::ElicitationAction::Cancel => Self::Cancel, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestParams { + pub thread_id: String, + /// Active Codex turn when this elicitation was observed, if app-server could correlate one. + /// + /// This is nullable because MCP models elicitation as a standalone server-to-client request + /// identified by the MCP server request id. It may be triggered during a turn, but turn + /// context is app-server correlation rather than part of the protocol identity of the + /// elicitation itself. + pub turn_id: Option, + pub server_name: String, + #[serde(flatten)] + pub request: McpServerElicitationRequest, + // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated + // McpToolCall item id here as an optional field. The current core event does not carry that + // association. +} + +/// Typed form schema for MCP `elicitation/create` requests. +/// +/// This matches the `requestedSchema` shape from the MCP 2025-11-25 +/// `ElicitRequestFormParams` schema. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationSchema { + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "$schema")] + pub schema_uri: Option, + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationObjectType, + pub properties: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub required: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationObjectType { + Object, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationPrimitiveSchema { + Enum(McpElicitationEnumSchema), + String(McpElicitationStringSchema), + Number(McpElicitationNumberSchema), + Boolean(McpElicitationBooleanSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationStringSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationStringType { + String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum McpElicitationStringFormat { + Email, + Uri, + Date, + DateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationNumberSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationNumberType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub maximum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationNumberType { + Number, + Integer, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationBooleanSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationBooleanType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationBooleanType { + Boolean, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationEnumSchema { + SingleSelect(McpElicitationSingleSelectEnumSchema), + MultiSelect(McpElicitationMultiSelectEnumSchema), + Legacy(McpElicitationLegacyTitledEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationLegacyTitledEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "enumNames")] + pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationSingleSelectEnumSchema { + Untitled(McpElicitationUntitledSingleSelectEnumSchema), + Titled(McpElicitationTitledSingleSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "oneOf")] + #[ts(rename = "oneOf")] + pub one_of: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationMultiSelectEnumSchema { + Untitled(McpElicitationUntitledMultiSelectEnumSchema), + Titled(McpElicitationTitledMultiSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationUntitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationTitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationArrayType { + Array, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledEnumItems { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledEnumItems { + #[serde(rename = "anyOf", alias = "oneOf")] + #[ts(rename = "anyOf")] + pub any_of: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationConstOption { + #[serde(rename = "const")] + #[ts(rename = "const")] + pub const_: String, + pub title: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationRequest { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Form { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + requested_schema: McpElicitationSchema, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Url { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + url: String, + elicitation_id: String, + }, +} + +impl TryFrom for McpServerElicitationRequest { + type Error = serde_json::Error; + + fn try_from(value: CoreElicitationRequest) -> Result { + match value { + CoreElicitationRequest::Form { + meta, + message, + requested_schema, + } => Ok(Self::Form { + meta, + message, + requested_schema: serde_json::from_value(requested_schema)?, + }), + CoreElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } => Ok(Self::Url { + meta, + message, + url, + elicitation_id, + }), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestResponse { + pub action: McpServerElicitationAction, + /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. + /// + /// This is nullable because decline/cancel responses have no content. + pub content: Option, + /// Optional client metadata for form-mode action handling. + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +impl From for rmcp::model::CreateElicitationResult { + fn from(value: McpServerElicitationRequestResponse) -> Self { + Self { + action: value.action.into(), + content: value.content, + } + } +} + +impl From for McpServerElicitationRequestResponse { + fn from(value: rmcp::model::CreateElicitationResult) -> Self { + Self { + action: value.action.into(), + content: value.content, + meta: None, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs new file mode 100644 index 0000000000..df8a363f82 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -0,0 +1,53 @@ +mod shared; + +mod account; +mod apps; +mod collaboration_mode; +mod command_exec; +mod config; +mod device_key; +mod experimental_feature; +mod feedback; +mod fs; +mod hook; +mod item; +mod mcp; +mod model; +mod notification; +mod permissions; +mod plugin; +mod process; +mod realtime; +mod review; +mod thread; +mod thread_data; +mod turn; +mod windows_sandbox; + +pub use account::*; +pub use apps::*; +pub use collaboration_mode::*; +pub use command_exec::*; +pub use config::*; +pub use device_key::*; +pub use experimental_feature::*; +pub use feedback::*; +pub use fs::*; +pub use hook::*; +pub use item::*; +pub use mcp::*; +pub use model::*; +pub use notification::*; +pub use permissions::*; +pub use plugin::*; +pub use process::*; +pub use realtime::*; +pub use review::*; +pub use shared::*; +pub use thread::*; +pub use thread_data::*; +pub use turn::*; +pub use windows_sandbox::*; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs new file mode 100644 index 0000000000..cd139e9c4b --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -0,0 +1,151 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; +use codex_protocol::protocol::ModelVerification as CoreModelVerification; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ModelRerouteReason from CoreModelRerouteReason { + HighRiskCyberActivity + } +); + +v2_enum_from_core!( + pub enum ModelVerification from CoreModelVerification { + TrustedAccessForCyber + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadResponse { + pub namespace_tools: bool, + pub image_generation: bool, + pub web_search: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// When true, include models that are hidden from the default picker list. + #[ts(optional = nullable)] + pub include_hidden: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelAvailabilityNux { + pub message: String, +} + +impl From for ModelAvailabilityNux { + fn from(value: CoreModelAvailabilityNux) -> Self { + Self { + message: value.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelServiceTier { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Model { + pub id: String, + pub model: String, + pub upgrade: Option, + pub upgrade_info: Option, + pub availability_nux: Option, + pub display_name: String, + pub description: String, + pub hidden: bool, + pub supported_reasoning_efforts: Vec, + pub default_reasoning_effort: ReasoningEffort, + #[serde(default = "default_input_modalities")] + pub input_modalities: Vec, + #[serde(default)] + pub supports_personality: bool, + /// Deprecated: use `serviceTiers` instead. + #[serde(default)] + pub additional_speed_tiers: Vec, + #[serde(default)] + pub service_tiers: Vec, + // Only one model should be marked as default. + pub is_default: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelUpgradeInfo { + pub model: String, + pub upgrade_copy: Option, + pub model_link: Option, + pub migration_markdown: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningEffortOption { + pub reasoning_effort: ReasoningEffort, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelReroutedNotification { + pub thread_id: String, + pub turn_id: String, + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelVerificationNotification { + pub thread_id: String, + pub turn_id: String, + pub verifications: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/notification.rs b/codex-rs/app-server-protocol/src/protocol/v2/notification.rs new file mode 100644 index 0000000000..8289cf5683 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/notification.rs @@ -0,0 +1,56 @@ +use super::TurnError; +use crate::RequestId; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeprecationNoticeNotification { + /// Concise summary of what is deprecated. + pub summary: String, + /// Optional extra guidance, such as migration steps or rationale. + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WarningNotification { + /// Optional thread target when the warning applies to a specific thread. + pub thread_id: Option, + /// Concise warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianWarningNotification { + /// Thread target for the guardian warning. + pub thread_id: String, + /// Concise guardian warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ErrorNotification { + pub error: TurnError, + // Set to true if the error is transient and the app-server process will automatically retry. + // If true, this will not interrupt a turn. + pub will_retry: bool, + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ServerRequestResolvedNotification { + pub thread_id: String, + pub request_id: RequestId, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs new file mode 100644 index 0000000000..8ce47e58cb --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -0,0 +1,854 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; +use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; +use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use ts_rs::TS; + +v2_enum_from_core! { + pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { + Http, + Https, + Socks5Tcp, + Socks5Udp, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + +impl From for NetworkApprovalContext { + fn from(value: CoreNetworkApprovalContext) -> Self { + Self { + host: value.host, + protocol: value.protocol.into(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalFileSystemPermissions { + /// This will be removed in favor of `entries`. + pub read: Option>, + /// This will be removed in favor of `entries`. + pub write: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub glob_scan_max_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub entries: Option>, +} + +impl From for AdditionalFileSystemPermissions { + fn from(value: CoreFileSystemPermissions) -> Self { + if let Some((read, write)) = value.legacy_read_write_roots() { + let mut entries = Vec::with_capacity( + read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), + ); + if let Some(paths) = read.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Read, + })); + } + if let Some(paths) = write.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Write, + })); + } + Self { + read, + write, + glob_scan_max_depth: None, + entries: Some(entries), + } + } else { + Self { + read: None, + write: None, + glob_scan_max_depth: value.glob_scan_max_depth, + entries: Some( + value + .entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + ), + } + } + } +} + +impl From for CoreFileSystemPermissions { + fn from(value: AdditionalFileSystemPermissions) -> Self { + let mut permissions = if let Some(entries) = value.entries { + Self { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth: None, + } + } else { + CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) + }; + permissions.glob_scan_max_depth = value.glob_scan_max_depth; + permissions + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalNetworkPermissions { + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileNetworkPermissions { + pub enabled: bool, +} + +impl From for AdditionalNetworkPermissions { + fn from(value: CoreNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for CoreNetworkPermissions { + fn from(value: AdditionalNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for PermissionProfileNetworkPermissions { + fn from(value: CoreNetworkSandboxPolicy) -> Self { + Self { + enabled: value.is_enabled(), + } + } +} + +impl From for CoreNetworkSandboxPolicy { + fn from(value: PermissionProfileNetworkPermissions) -> Self { + if value.enabled { + Self::Enabled + } else { + Self::Restricted + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct RequestPermissionProfile { + pub network: Option, + pub file_system: Option, +} + +impl From for RequestPermissionProfile { + fn from(value: CoreRequestPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreRequestPermissionProfile { + fn from(value: RequestPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +v2_enum_from_core!( + pub enum FileSystemAccessMode from CoreFileSystemAccessMode { + Read, + Write, + None + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(tag = "kind")] +#[ts(export_to = "v2/")] +pub enum FileSystemSpecialPath { + Root, + Minimal, + #[serde(alias = "current_working_directory")] + ProjectRoots { + subpath: Option, + }, + Tmpdir, + SlashTmp, + Unknown { + path: String, + subpath: Option, + }, +} + +impl From for FileSystemSpecialPath { + fn from(value: CoreFileSystemSpecialPath) -> Self { + match value { + CoreFileSystemSpecialPath::Root => Self::Root, + CoreFileSystemSpecialPath::Minimal => Self::Minimal, + CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, + CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, + CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +impl From for CoreFileSystemSpecialPath { + fn from(value: FileSystemSpecialPath) -> Self { + match value { + FileSystemSpecialPath::Root => Self::Root, + FileSystemSpecialPath::Minimal => Self::Minimal, + FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + FileSystemSpecialPath::Tmpdir => Self::Tmpdir, + FileSystemSpecialPath::SlashTmp => Self::SlashTmp, + FileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum FileSystemPath { + Path { path: AbsolutePathBuf }, + GlobPattern { pattern: String }, + Special { value: FileSystemSpecialPath }, +} + +impl From for FileSystemPath { + fn from(value: CoreFileSystemPath) -> Self { + match value { + CoreFileSystemPath::Path { path } => Self::Path { path }, + CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + CoreFileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +impl From for CoreFileSystemPath { + fn from(value: FileSystemPath) -> Self { + match value { + FileSystemPath::Path { path } => Self::Path { path }, + FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + FileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileSystemSandboxEntry { + pub path: FileSystemPath, + pub access: FileSystemAccessMode, +} + +impl From for FileSystemSandboxEntry { + fn from(value: CoreFileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.into(), + } + } +} + +impl From for CoreFileSystemSandboxEntry { + fn from(value: FileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.to_core(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileFileSystemPermissions { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl From for PermissionProfileFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl From for CoreManagedFileSystemPermissions { + fn from(value: PermissionProfileFileSystemPermissions) -> Self { + match value { + PermissionProfileFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfile { + /// Codex owns sandbox construction for this profile. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Managed { + network: PermissionProfileNetworkPermissions, + file_system: PermissionProfileFileSystemPermissions, + }, + /// Do not apply an outer sandbox. + Disabled, + /// Filesystem isolation is enforced by an external caller. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + External { + network: PermissionProfileNetworkPermissions, + }, +} + +impl From for PermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + network: network.into(), + file_system: file_system.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl From for CorePermissionProfile { + fn from(value: PermissionProfile) -> Self { + match value { + PermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + PermissionProfile::Disabled => Self::Disabled, + PermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ActivePermissionProfile { + /// Identifier from `default_permissions` or the implicit built-in default, + /// such as `:workspace` or a user-defined `[permissions.]` profile. + pub id: String, + /// Parent profile identifier once permissions profiles support + /// inheritance. This is currently always `null`. + #[serde(default)] + pub extends: Option, + /// Bounded user-requested modifications applied on top of the named + /// profile, if any. + #[serde(default)] + pub modifications: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ActivePermissionProfileModification { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +impl From for ActivePermissionProfileModification { + fn from(value: CoreActivePermissionProfileModification) -> Self { + match value { + CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for CoreActivePermissionProfileModification { + fn from(value: ActivePermissionProfileModification) -> Self { + match value { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(ActivePermissionProfileModification::from) + .collect(), + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(CoreActivePermissionProfileModification::from) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileSelectionParams { + /// Select a named built-in or user-defined profile and optionally apply + /// bounded modifications that Codex knows how to validate. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Profile { + id: String, + #[ts(optional = nullable)] + modifications: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileModificationParams { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalPermissionProfile { + /// Partial overlay used for per-command permission requests. + pub network: Option, + pub file_system: Option, +} + +impl From for AdditionalPermissionProfile { + fn from(value: CoreAdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: AdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedPermissionProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub file_system: Option, +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: GrantedPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NetworkAccess { + #[default] + Restricted, + Enabled, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum SandboxPolicy { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxPolicyDeserialize { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + #[serde(default)] + access: Option, + }, + #[serde(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + read_only_access: Option, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum LegacyReadOnlyAccess { + FullAccess, + Restricted, +} + +impl<'de> Deserialize<'de> for SandboxPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match SandboxPolicyDeserialize::deserialize(deserializer)? { + SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), + SandboxPolicyDeserialize::ReadOnly { + network_access, + access, + } => { + if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "readOnly.access is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::ReadOnly { network_access }) + } + SandboxPolicyDeserialize::ExternalSandbox { network_access } => { + Ok(SandboxPolicy::ExternalSandbox { network_access }) + } + SandboxPolicyDeserialize::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) + } + } + } +} + +impl SandboxPolicy { + pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { + match self { + SandboxPolicy::DangerFullAccess => { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + } + SandboxPolicy::ReadOnly { network_access } => { + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: *network_access, + } + } + SandboxPolicy::ExternalSandbox { network_access } => { + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: match network_access { + NetworkAccess::Restricted => CoreNetworkAccess::Restricted, + NetworkAccess::Enabled => CoreNetworkAccess::Enabled, + }, + } + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + } + } +} + +impl From for SandboxPolicy { + fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { + match value { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { + SandboxPolicy::ReadOnly { network_access } + } + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { + SandboxPolicy::ExternalSandbox { + network_access: match network_access { + CoreNetworkAccess::Restricted => NetworkAccess::Restricted, + CoreNetworkAccess::Enabled => NetworkAccess::Enabled, + }, + } + } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array", export_to = "v2/")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn into_core(self) -> CoreExecPolicyAmendment { + CoreExecPolicyAmendment::new(self.command) + } +} + +impl From for ExecPolicyAmendment { + fn from(value: CoreExecPolicyAmendment) -> Self { + Self { + command: value.command().to_vec(), + } + } +} + +v2_enum_from_core!( + pub enum NetworkPolicyRuleAction from CoreNetworkPolicyRuleAction { + Allow, Deny + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkPolicyAmendment { + pub host: String, + pub action: NetworkPolicyRuleAction, +} + +impl NetworkPolicyAmendment { + pub fn into_core(self) -> CoreNetworkPolicyAmendment { + CoreNetworkPolicyAmendment { + host: self.host, + action: self.action.to_core(), + } + } +} + +impl From for NetworkPolicyAmendment { + fn from(value: CoreNetworkPolicyAmendment) -> Self { + Self { + host: value.host, + action: NetworkPolicyRuleAction::from(value.action), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub cwd: AbsolutePathBuf, + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +v2_enum_from_core!( + #[derive(Default)] + pub enum PermissionGrantScope from CorePermissionGrantScope { + #[default] + Turn, + Session + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalResponse { + pub permissions: GrantedPermissionProfile, + #[serde(default)] + pub scope: PermissionGrantScope, + /// Review every subsequent command in this turn before normal sandboxed execution. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub strict_auto_review: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs new file mode 100644 index 0000000000..3a7651724d --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -0,0 +1,715 @@ +use super::AppSummary; +use super::HookEventName; +use super::HookHandlerType; +use super::HookSource; +use super::HookTrustStatus; +use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; +use codex_protocol::protocol::SkillInterface as CoreSkillInterface; +use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; +use codex_protocol::protocol::SkillScope as CoreSkillScope; +use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, + + /// When true, bypass the skills cache and re-scan skills from disk. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_reload: bool, + + /// Optional per-cwd extra roots to scan as user-scoped skills. + #[serde(default)] + #[ts(optional = nullable)] + pub per_cwd_extra_user_roots: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListExtraRootsForCwd { + pub cwd: PathBuf, + pub extra_user_roots: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddParams { + pub source: String, + #[ts(optional = nullable)] + pub ref_name: Option, + #[ts(optional = nullable)] + pub sparse_paths: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddResponse { + pub marketplace_name: String, + pub installed_root: AbsolutePathBuf, + pub already_added: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveParams { + pub marketplace_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveResponse { + pub marketplace_name: String, + pub installed_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeParams { + #[ts(optional = nullable)] + pub marketplace_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeResponse { + pub selected_marketplaces: Vec, + pub upgraded_roots: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeErrorInfo { + pub marketplace_name: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListParams { + /// Optional working directories used to discover repo marketplaces. When omitted, + /// only home-scoped marketplaces and the official curated marketplace are considered. + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListResponse { + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, + #[serde(default)] + pub featured_plugin_ids: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceLoadErrorInfo { + pub marketplace_path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadResponse { + pub plugin: PluginDetail, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadParams { + pub remote_marketplace_name: String, + pub remote_plugin_id: String, + pub skill_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadResponse { + pub contents: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveParams { + pub plugin_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub remote_plugin_id: Option, + #[ts(optional = nullable)] + pub discoverability: Option, + #[ts(optional = nullable)] + pub share_targets: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveResponse { + pub remote_plugin_id: String, + pub share_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsParams { + pub remote_plugin_id: String, + pub share_targets: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsResponse { + pub principals: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteParams { + pub remote_plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListItem { + pub plugin: PluginSummary, + pub share_url: String, + pub local_plugin_path: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginShareDiscoverability { + #[serde(rename = "LISTED")] + #[ts(rename = "LISTED")] + Listed, + #[serde(rename = "UNLISTED")] + #[ts(rename = "UNLISTED")] + Unlisted, + #[serde(rename = "PRIVATE")] + #[ts(rename = "PRIVATE")] + Private, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginSharePrincipalType { + #[serde(rename = "user")] + #[ts(rename = "user")] + User, + #[serde(rename = "group")] + #[ts(rename = "group")] + Group, + #[serde(rename = "workspace")] + #[ts(rename = "workspace")] + Workspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareTarget { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSharePrincipal { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SkillScope { + User, + Repo, + System, + Admin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillMetadata { + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. + pub short_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub interface: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub dependencies: Option, + pub path: AbsolutePathBuf, + pub scope: SkillScope, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillInterface { + #[ts(optional)] + pub display_name: Option, + #[ts(optional)] + pub short_description: Option, + #[ts(optional)] + pub icon_small: Option, + #[ts(optional)] + pub icon_large: Option, + #[ts(optional)] + pub brand_color: Option, + #[ts(optional)] + pub default_prompt: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillDependencies { + pub tools: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillToolDependency { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub r#type: String, + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListEntry { + pub cwd: PathBuf, + pub hooks: Vec, + pub warnings: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMetadata { + pub key: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub display_order: i64, + pub enabled: bool, + pub is_managed: bool, + pub current_hash: String, + pub trust_status: HookTrustStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginMarketplaceEntry { + pub name: String, + /// Local marketplace file path when the marketplace is backed by a local file. + /// Remote-only catalog marketplaces do not have a local path. + pub path: Option, + pub interface: Option, + pub plugins: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceInterface { + pub display_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginInstallPolicy { + #[serde(rename = "NOT_AVAILABLE")] + #[ts(rename = "NOT_AVAILABLE")] + NotAvailable, + #[serde(rename = "AVAILABLE")] + #[ts(rename = "AVAILABLE")] + Available, + #[serde(rename = "INSTALLED_BY_DEFAULT")] + #[ts(rename = "INSTALLED_BY_DEFAULT")] + InstalledByDefault, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAuthPolicy { + #[serde(rename = "ON_INSTALL")] + #[ts(rename = "ON_INSTALL")] + OnInstall, + #[serde(rename = "ON_USE")] + #[ts(rename = "ON_USE")] + OnUse, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAvailability { + /// Plugin-service currently sends `"ENABLED"` for available remote plugins. + /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps + /// decoding compatible with that upstream response. + #[serde(rename = "AVAILABLE", alias = "ENABLED")] + #[ts(rename = "AVAILABLE")] + #[default] + Available, + #[serde(rename = "DISABLED_BY_ADMIN")] + #[ts(rename = "DISABLED_BY_ADMIN")] + DisabledByAdmin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSummary { + pub id: String, + pub name: String, + pub source: PluginSource, + pub installed: bool, + pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + /// Availability state for installing and using the plugin. + #[serde(default)] + pub availability: PluginAvailability, + pub interface: Option, + #[serde(default)] + pub keywords: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginDetail { + pub marketplace_name: String, + pub marketplace_path: Option, + pub summary: PluginSummary, + pub description: Option, + pub skills: Vec, + pub apps: Vec, + pub mcp_servers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillSummary { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, + pub path: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInterface { + pub display_name: Option, + pub short_description: Option, + pub long_description: Option, + pub developer_name: Option, + pub category: Option, + pub capabilities: Vec, + pub website_url: Option, + pub privacy_policy_url: Option, + pub terms_of_service_url: Option, + /// Starter prompts for the plugin. Capped at 3 entries with a maximum of + /// 128 characters per entry. + pub default_prompt: Option>, + pub brand_color: Option, + /// Local composer icon path, resolved from the installed plugin package. + pub composer_icon: Option, + /// Remote composer icon URL from the plugin catalog. + pub composer_icon_url: Option, + /// Local logo path, resolved from the installed plugin package. + pub logo: Option, + /// Remote logo URL from the plugin catalog. + pub logo_url: Option, + /// Local screenshot paths, resolved from the installed plugin package. + pub screenshots: Vec, + /// Remote screenshot URLs from the plugin catalog. + pub screenshot_urls: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PluginSource { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Local { path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Git { + url: String, + path: Option, + ref_name: Option, + sha: Option, + }, + /// The plugin is available in the remote catalog. Download metadata is + /// kept server-side and is not exposed through the app-server API. + Remote, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteParams { + /// Path-based selector. + #[ts(optional = nullable)] + pub path: Option, + /// Name-based selector. + #[ts(optional = nullable)] + pub name: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteResponse { + pub effective_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallResponse { + pub auth_policy: PluginAuthPolicy, + pub apps_needing_auth: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallParams { + pub plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallResponse {} + +impl From for SkillMetadata { + fn from(value: CoreSkillMetadata) -> Self { + Self { + name: value.name, + description: value.description, + short_description: value.short_description, + interface: value.interface.map(SkillInterface::from), + dependencies: value.dependencies.map(SkillDependencies::from), + path: value.path, + scope: value.scope.into(), + enabled: true, + } + } +} + +impl From for SkillInterface { + fn from(value: CoreSkillInterface) -> Self { + Self { + display_name: value.display_name, + short_description: value.short_description, + brand_color: value.brand_color, + default_prompt: value.default_prompt, + icon_small: value.icon_small, + icon_large: value.icon_large, + } + } +} + +impl From for SkillDependencies { + fn from(value: CoreSkillDependencies) -> Self { + Self { + tools: value + .tools + .into_iter() + .map(SkillToolDependency::from) + .collect(), + } + } +} + +impl From for SkillToolDependency { + fn from(value: CoreSkillToolDependency) -> Self { + Self { + r#type: value.r#type, + value: value.value, + description: value.description, + transport: value.transport, + command: value.command, + url: value.url, + } + } +} + +impl From for SkillScope { + fn from(value: CoreSkillScope) -> Self { + match value { + CoreSkillScope::User => Self::User, + CoreSkillScope::Repo => Self::Repo, + CoreSkillScope::System => Self::System, + CoreSkillScope::Admin => Self::Admin, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification emitted when watched local skill files change. +/// +/// Treat this as an invalidation signal and re-run `skills/list` with the +/// client's current parameters when refreshed skill metadata is needed. +pub struct SkillsChangedNotification {} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/process.rs b/codex-rs/app-server-protocol/src/protocol/v2/process.rs new file mode 100644 index 0000000000..b70847165e --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/process.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +/// PTY size in character cells for `process/spawn` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Spawn a standalone process (argv vector) without a Codex sandbox on the host +/// where the app server is running. +/// +/// `process/spawn` returns after the process has started and the connection-scoped +/// `processHandle` has been registered. Process output and exit are reported via +/// `process/outputDelta` and `process/exited` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Client-supplied, connection-scoped process handle. + /// + /// Duplicate active handles are rejected on the same connection. The same + /// handle can be reused after the prior process exits. + pub process_handle: String, + /// Absolute working directory for the process. + pub cwd: AbsolutePathBuf, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `process/writeStdin` requests to write stdin bytes. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `process/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the `process/exited` notification. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// cap. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option>, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// timeout. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option>, + /// Optional environment overrides merged into the app-server process + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, +} + +/// Successful response for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnResponse {} + +/// Write stdin bytes to a running `process/spawn` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `process/writeStdin`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinResponse {} + +/// Terminate a running `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, +} + +/// Empty success response for `process/kill`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillResponse {} + +/// Resize a running PTY-backed `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// New PTY size in character cells. + pub size: ProcessTerminalSize, +} + +/// Empty success response for `process/resizePty`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyResponse {} + +/// Stream label for `process/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ProcessOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} + +/// Base64-encoded output chunk emitted for a streaming `process/spawn` request. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessOutputDeltaNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Output stream this chunk belongs to. + pub stream: ProcessOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// True on the final streamed chunk for this stream when output was + /// truncated by `outputBytesCap`. + pub cap_reached: bool, +} + +/// Final process exit notification for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessExitedNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `process/outputDelta`. + pub stdout: String, + /// Whether stdout reached `outputBytesCap`. + /// + /// In streaming mode, stdout is empty and cap state is also reported on the + /// final stdout `process/outputDelta` notification. + pub stdout_cap_reached: bool, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `process/outputDelta`. + pub stderr: String, + /// Whether stderr reached `outputBytesCap`. + /// + /// In streaming mode, stderr is empty and cap state is also reported on the + /// final stderr `process/outputDelta` notification. + pub stderr_cap_reached: bool, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs new file mode 100644 index 0000000000..c6ea0744de --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -0,0 +1,241 @@ +use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationVersion; +use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeVoice; +use codex_protocol::protocol::RealtimeVoicesList; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +/// EXPERIMENTAL - thread realtime audio chunk. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAudioChunk { + pub data: String, + pub sample_rate: u32, + pub num_channels: u16, + pub samples_per_channel: Option, + pub item_id: Option, +} + +impl From for ThreadRealtimeAudioChunk { + fn from(value: CoreRealtimeAudioFrame) -> Self { + let CoreRealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +impl From for CoreRealtimeAudioFrame { + fn from(value: ThreadRealtimeAudioChunk) -> Self { + let ThreadRealtimeAudioChunk { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +/// EXPERIMENTAL - start a thread-scoped realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartParams { + pub thread_id: String, + /// Selects text or audio output for the realtime session. Transport and voice stay + /// independent so clients can choose how they connect separately from what the model emits. + pub output_modality: RealtimeOutputModality, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub prompt: Option>, + #[ts(optional = nullable)] + pub realtime_session_id: Option, + #[ts(optional = nullable)] + pub transport: Option, + #[ts(optional = nullable)] + pub voice: Option, +} + +/// EXPERIMENTAL - transport used by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/", tag = "type")] +pub enum ThreadRealtimeStartTransport { + Websocket, + Webrtc { + /// SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the + /// realtime events data channel. + sdp: String, + }, +} + +/// EXPERIMENTAL - response for starting thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartResponse {} + +/// EXPERIMENTAL - append audio input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioParams { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - response for appending realtime audio input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioResponse {} + +/// EXPERIMENTAL - append text input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextParams { + pub thread_id: String, + pub text: String, +} + +/// EXPERIMENTAL - response for appending realtime text input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextResponse {} + +/// EXPERIMENTAL - stop thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopParams { + pub thread_id: String, +} + +/// EXPERIMENTAL - response for stopping thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopResponse {} + +/// EXPERIMENTAL - list voices supported by thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesParams {} + +/// EXPERIMENTAL - response for listing supported realtime voices. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesResponse { + pub voices: RealtimeVoicesList, +} + +/// EXPERIMENTAL - emitted when thread realtime startup is accepted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartedNotification { + pub thread_id: String, + pub realtime_session_id: Option, + pub version: RealtimeConversationVersion, +} + +/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeItemAddedNotification { + pub thread_id: String, + pub item: JsonValue, +} + +/// EXPERIMENTAL - flat transcript delta emitted whenever realtime +/// transcript text changes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDeltaNotification { + pub thread_id: String, + pub role: String, + /// Live transcript delta from the realtime event. + pub delta: String, +} + +/// EXPERIMENTAL - final transcript text emitted when realtime completes +/// a transcript part. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDoneNotification { + pub thread_id: String, + pub role: String, + /// Final complete text for the transcript part. + pub text: String, +} + +/// EXPERIMENTAL - streamed output audio emitted by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeOutputAudioDeltaNotification { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeSdpNotification { + pub thread_id: String, + pub sdp: String, +} + +/// EXPERIMENTAL - emitted when thread realtime encounters an error. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeErrorNotification { + pub thread_id: String, + pub message: String, +} + +/// EXPERIMENTAL - emitted when thread realtime transport closes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeClosedNotification { + pub thread_id: String, + pub reason: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/review.rs b/codex-rs/app-server-protocol/src/protocol/v2/review.rs new file mode 100644 index 0000000000..82ec5b6f59 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/review.rs @@ -0,0 +1,65 @@ +use super::Turn; +use super::shared::v2_enum_from_core; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { + Inline, Detached + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartParams { + pub thread_id: String, + pub target: ReviewTarget, + + /// Where to run the review: inline (default) on the current thread or + /// detached on a new thread (returned in `reviewThreadId`). + #[serde(default)] + #[ts(optional = nullable)] + pub delivery: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartResponse { + pub turn: Turn, + /// Identifies the thread where the review runs. + /// + /// For inline reviews, this is the original thread id. + /// For detached reviews, this is the id of the new review thread. + pub review_thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ReviewTarget { + /// Review the working tree: staged, unstaged, and untracked files. + UncommittedChanges, + + /// Review changes between the current branch and the given base branch. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + BaseBranch { branch: String }, + + /// Review the changes introduced by a specific commit. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Commit { + sha: String, + /// Optional human-readable label (e.g., commit subject) for UIs. + title: Option, + }, + + /// Arbitrary instructions, equivalent to the old free-form prompt. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Custom { instructions: String }, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs new file mode 100644 index 0000000000..9ec1fb80cb --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -0,0 +1,316 @@ +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +// Macro to declare a camelCased API v2 enum mirroring a core enum which +// tends to use either snake_case or kebab-case. +macro_rules! v2_enum_from_core { + ( + $(#[$enum_meta:meta])* + pub enum $Name:ident from $Src:path { + $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? + } + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + $(#[$enum_meta])* + #[serde(rename_all = "camelCase")] + #[ts(export_to = "v2/")] + pub enum $Name { + $( $(#[$variant_meta])* $Variant ),+ + } + + impl $Name { + pub fn to_core(self) -> $Src { + match self { $( $Name::$Variant => <$Src>::$Variant ),+ } + } + } + + impl From<$Src> for $Name { + fn from(value: $Src) -> Self { + match value { $( <$Src>::$Variant => $Name::$Variant ),+ } + } + } + }; +} + +pub(super) use v2_enum_from_core; + +pub(super) const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + +/// This translation layer make sure that we expose codex error code in camel case. +/// +/// When an upstream HTTP status is available (for example, from the Responses API or a provider), +/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CodexErrorInfo { + ContextWindowExceeded, + UsageLimitExceeded, + ServerOverloaded, + CyberPolicy, + HttpConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Failed to connect to the response SSE stream. + ResponseStreamConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + InternalServerError, + Unauthorized, + BadRequest, + ThreadRollbackFailed, + SandboxError, + /// The response SSE stream disconnected in the middle of a turn before completion. + ResponseStreamDisconnected { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Reached the retry limit for responses. + ResponseTooManyFailedAttempts { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, + Other, +} + +impl From for CodexErrorInfo { + fn from(value: CoreCodexErrorInfo) -> Self { + match value { + CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, + CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, + CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, + CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { + CodexErrorInfo::HttpConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { + CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, + CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, + CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, + CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, + CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, + CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { + CodexErrorInfo::ResponseStreamDisconnected { http_status_code } + } + CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } + } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } + CoreCodexErrorInfo::Other => CodexErrorInfo::Other, + } + } +} + +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + #[experimental("askForApproval.granular")] + Granular { + sandbox_approval: bool, + rules: bool, + #[serde(default)] + skill_approval: bool, + #[serde(default)] + request_permissions: bool, + mcp_elicitations: bool, + }, + Never, +} + +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Granular { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + } => CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + }), + AskForApproval::Never => CoreAskForApproval::Never, + } + } +} + +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { + sandbox_approval: granular_config.sandbox_approval, + rules: granular_config.rules, + skill_approval: granular_config.skill_approval, + request_permissions: granular_config.request_permissions, + mcp_elicitations: granular_config.mcp_elicitations, + }, + CoreAskForApproval::Never => AskForApproval::Never, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[ts( + type = r#""user" | "auto_review" | "guardian_subagent""#, + export_to = "v2/" +)] +/// Configures who approval requests are routed to for review. Examples +/// include sandbox escapes, blocked network access, MCP approval prompts, and +/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully +/// prompted subagent to gather relevant context and apply a risk-based +/// decision framework before approving or denying the request. +pub enum ApprovalsReviewer { + #[serde(rename = "user")] + User, + #[serde(rename = "guardian_subagent", alias = "auto_review")] + AutoReview, +} + +impl JsonSchema for ApprovalsReviewer { + fn schema_name() -> String { + "ApprovalsReviewer".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + string_enum_schema_with_description( + &["user", "auto_review", "guardian_subagent"], + "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + ) + } +} + +fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some(description.to_string()), + ..Default::default() + })), + ..Default::default() + }; + schema.enum_values = Some( + values + .iter() + .map(|value| JsonValue::String((*value).to_string())) + .collect(), + ); + Schema::Object(schema) +} + +impl ApprovalsReviewer { + pub fn to_core(self) -> CoreApprovalsReviewer { + match self { + ApprovalsReviewer::User => CoreApprovalsReviewer::User, + ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, + } + } +} + +impl From for ApprovalsReviewer { + fn from(value: CoreApprovalsReviewer) -> Self { + match value { + CoreApprovalsReviewer::User => ApprovalsReviewer::User, + CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs new file mode 100644 index 0000000000..784448925f --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -0,0 +1,3633 @@ +use super::*; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::FileChangeItem; +use codex_protocol::items::ImageViewItem; +use codex_protocol::items::McpToolCallItem; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::ReasoningItem; +use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; +use codex_protocol::items::WebSearchItem; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::time::Duration; + +fn absolute_path_string(path: &str) -> String { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).display().to_string() +} + +fn absolute_path(path: &str) -> AbsolutePathBuf { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).abs() +} + +fn test_absolute_path() -> AbsolutePathBuf { + absolute_path("readable") +} + +#[test] +fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), + "\"guardian_subagent\"" + ); + + for value in ["user", "auto_review", "guardian_subagent"] { + let json = format!("\"{value}\""); + let reviewer: ApprovalsReviewer = + serde_json::from_str(&json).expect("deserialize reviewer"); + let expected = if value == "user" { + ApprovalsReviewer::User + } else { + ApprovalsReviewer::AutoReview + }; + assert_eq!(expected, reviewer); + } +} + +#[test] +fn turn_defaults_legacy_missing_items_view_to_full() { + let turn: Turn = serde_json::from_value(json!({ + "id": "turn_123", + "items": [], + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null, + })) + .expect("legacy turn should deserialize"); + + assert_eq!(turn.items_view, TurnItemsView::Full); +} + +#[test] +fn thread_list_params_accepts_single_cwd() { + let params = serde_json::from_value::(json!({ + "cwd": "/workspace", + })) + .expect("single cwd should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One("/workspace".to_string())) + ); + assert!(!params.use_state_db_only); +} + +#[test] +fn thread_list_params_accepts_multiple_cwds() { + let params = serde_json::from_value::(json!({ + "cwd": ["/workspace", "/other-workspace"], + })) + .expect("cwd array should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::Many(vec![ + "/workspace".to_string(), + "/other-workspace".to_string(), + ])) + ); +} + +#[test] +fn thread_list_params_accepts_state_db_only_flag() { + let params = serde_json::from_value::(json!({ + "useStateDbOnly": true, + })) + .expect("state db only flag should deserialize"); + + assert!(params.use_state_db_only); +} + +#[test] +fn collab_agent_state_maps_interrupted_status() { + assert_eq!( + CollabAgentState::from(CoreAgentStatus::Interrupted), + CollabAgentState { + status: CollabAgentStatus::Interrupted, + message: None, + } + ); +} + +#[test] +fn external_agent_config_plugins_details_round_trip() { + let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + })) + .expect("plugins migration item should deserialize"); + + assert_eq!( + item, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + } + ); +} + +#[test] +fn external_agent_config_import_params_accept_legacy_plugin_details() { + let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({ + "migrationItems": [{ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + }] + })) + .expect("legacy plugin import params should deserialize"); + + assert_eq!( + params, + ExternalAgentConfigImportParams { + migration_items: vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + }], + } + ); +} + +#[test] +fn command_execution_request_approval_rejects_relative_additional_permission_paths() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "command": "cat file", + "cwd": absolute_path_string("tmp"), + "commandActions": null, + "reason": null, + "networkApprovalContext": null, + "additionalPermissions": { + "network": null, + "fileSystem": { + "read": ["relative/path"], + "write": null + } + }, + "proposedExecpolicyAmendment": null, + "proposedNetworkPolicyAmendments": null, + "availableDecisions": null + })) + .expect_err("relative additional permission paths should fail"); + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} + +#[test] +fn permissions_request_approval_uses_request_permission_profile() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions request should deserialize"); + + assert_eq!(params.cwd, absolute_path("repo")); + assert_eq!( + params.permissions, + RequestPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreRequestPermissionProfile::from(params.permissions), + CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_rejects_macos_permissions() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": null, + "fileSystem": null, + "macos": { + "preferences": "read_only", + "automations": "none", + "launchServices": false, + "accessibility": false, + "calendar": false, + "reminders": false, + "contacts": "none", + }, + }, + })) + .expect_err("permissions request should reject macos permissions"); + + assert!( + err.to_string().contains("unknown field `macos`"), + "unexpected error: {err}" + ); +} + +#[test] +fn additional_file_system_permissions_preserves_canonical_entries() { + let core_permissions = CoreFileSystemPermissions { + entries: vec![ + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::Special { + value: CoreFileSystemSpecialPath::Root, + }, + access: CoreFileSystemAccessMode::Write, + }, + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: None, + write: None, + glob_scan_max_depth: NonZeroUsize::new(2), + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_populates_entries_for_legacy_roots() { + let read_only_path = absolute_path("read-only"); + let read_write_path = absolute_path("read-write"); + let core_permissions = CoreFileSystemPermissions::from_read_write_roots( + Some(vec![read_only_path.clone()]), + Some(vec![read_write_path.clone()]), + ); + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: Some(vec![read_only_path.clone()]), + write: Some(vec![read_write_path.clone()]), + glob_scan_max_depth: None, + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_only_path, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_write_path, + }, + access: FileSystemAccessMode::Write, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "read": null, + "write": null, + "globScanMaxDepth": 0, + "entries": [], + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { + let core_permissions = CoreManagedFileSystemPermissions::Restricted { + entries: vec![CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + } + ); + assert_eq!( + CoreManagedFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "type": "restricted", + "entries": [], + "globScanMaxDepth": 0, + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { + let special_path = serde_json::from_value::(json!({ + "kind": "current_working_directory", + })) + .expect("legacy cwd special path should deserialize"); + + assert_eq!( + special_path, + FileSystemSpecialPath::ProjectRoots { subpath: None } + ); + assert_eq!( + serde_json::to_value(&special_path).expect("serialize special path"), + json!({ + "kind": "project_roots", + "subpath": null, + }) + ); +} + +#[test] +fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let response = serde_json::from_value::(json!({ + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions response should deserialize"); + + assert_eq!( + response.permissions, + GrantedPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreAdditionalPermissionProfile::from(response.permissions), + CoreAdditionalPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_response_defaults_scope_to_turn() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + })) + .expect("response should deserialize"); + + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert_eq!(response.strict_auto_review, None); +} + +#[test] +fn permissions_request_approval_response_accepts_strict_auto_review() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + "strictAutoReview": true, + })) + .expect("response should deserialize"); + + assert_eq!(response.strict_auto_review, Some(true)); +} + +#[test] +fn fs_get_metadata_response_round_trips_minimal_fields() { + let response = FsGetMetadataResponse { + is_directory: false, + is_file: true, + is_symlink: false, + created_at_ms: 123, + modified_at_ms: 456, + }; + + let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); + assert_eq!( + value, + json!({ + "isDirectory": false, + "isFile": true, + "isSymlink": false, + "createdAtMs": 123, + "modifiedAtMs": 456, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/getMetadata response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_response_round_trips_base64_data() { + let response = FsReadFileResponse { + data_base64: "aGVsbG8=".to_string(), + }; + + let value = serde_json::to_value(&response).expect("serialize fs/readFile response"); + assert_eq!( + value, + json!({ + "dataBase64": "aGVsbG8=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/readFile response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_params_round_trip() { + let params = FsReadFileParams { + path: absolute_path("tmp/example.txt"), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.txt"), + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/readFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn device_key_create_params_round_trip_uses_protection_policy() { + let params = DeviceKeyCreateParams { + protection_policy: None, + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize device/key/create params"); + assert_eq!( + value, + json!({ + "accountUserId": "account-user-1", + "clientId": "cli_123", + "protectionPolicy": null, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/create params"); + assert_eq!(decoded, params); + + let params = DeviceKeyCreateParams { + protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable), + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + }; + let value = serde_json::to_value(¶ms) + .expect("serialize device/key/create params with protection policy"); + assert_eq!( + value, + json!({ + "accountUserId": "account-user-1", + "clientId": "cli_123", + "protectionPolicy": "allow_os_protected_nonextractable", + }) + ); +} + +#[test] +fn device_key_create_response_round_trips_protection_class() { + let response = DeviceKeyCreateResponse { + key_id: "dk_123".to_string(), + public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(), + algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, + protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable, + }; + + let value = serde_json::to_value(&response).expect("serialize device/key/create response"); + assert_eq!( + value, + json!({ + "keyId": "dk_123", + "publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE", + "algorithm": "ecdsa_p256_sha256", + "protectionClass": "os_protected_nonextractable", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/create response"); + assert_eq!(decoded, response); +} + +#[test] +fn device_key_sign_params_round_trip_uses_accepted_payload_enum() { + let params = DeviceKeySignParams { + key_id: "dk_123".to_string(), + payload: DeviceKeySignPayload::RemoteControlClientConnection { + nonce: "nonce-1".to_string(), + audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket, + session_id: "wssess_123".to_string(), + target_origin: "https://chatgpt.com".to_string(), + target_path: "/api/codex/remote/control/client".to_string(), + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(), + token_expires_at: 1_700_000_000, + scopes: vec!["remote_control_controller_websocket".to_string()], + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize device/key/sign params"); + assert_eq!( + value, + json!({ + "keyId": "dk_123", + "payload": { + "type": "remoteControlClientConnection", + "nonce": "nonce-1", + "audience": "remote_control_client_websocket", + "sessionId": "wssess_123", + "targetOrigin": "https://chatgpt.com", + "targetPath": "/api/codex/remote/control/client", + "accountUserId": "account-user-1", + "clientId": "cli_123", + "tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", + "tokenExpiresAt": 1_700_000_000, + "scopes": ["remote_control_controller_websocket"], + }, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/sign params"); + assert_eq!(decoded, params); +} + +#[test] +fn device_key_sign_params_round_trip_uses_enrollment_payload() { + let params = DeviceKeySignParams { + key_id: "dk_123".to_string(), + payload: DeviceKeySignPayload::RemoteControlClientEnrollment { + nonce: "nonce-1".to_string(), + audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment, + challenge_id: "rch_123".to_string(), + target_origin: "https://chatgpt.com".to_string(), + target_path: "/wham/remote/control/client/enroll".to_string(), + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" + .to_string(), + challenge_expires_at: 1_700_000_000, + }, + }; + + let value = serde_json::to_value(¶ms) + .expect("serialize device/key/sign params with enrollment payload"); + assert_eq!( + value, + json!({ + "keyId": "dk_123", + "payload": { + "type": "remoteControlClientEnrollment", + "nonce": "nonce-1", + "audience": "remote_control_client_enrollment", + "challengeId": "rch_123", + "targetOrigin": "https://chatgpt.com", + "targetPath": "/wham/remote/control/client/enroll", + "accountUserId": "account-user-1", + "clientId": "cli_123", + "deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", + "challengeExpiresAt": 1_700_000_000, + }, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/sign params with enrollment payload"); + assert_eq!(decoded, params); +} + +#[test] +fn device_key_sign_response_returns_signed_payload_bytes() { + let response = DeviceKeySignResponse { + signature_der_base64: "MEUCIQD".to_string(), + signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(), + algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, + }; + + let value = serde_json::to_value(&response).expect("serialize device/key/sign response"); + assert_eq!( + value, + json!({ + "signatureDerBase64": "MEUCIQD", + "signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA", + "algorithm": "ecdsa_p256_sha256", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/sign response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_create_directory_params_round_trip_with_default_recursive() { + let params = FsCreateDirectoryParams { + path: absolute_path("tmp/example"), + recursive: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example"), + "recursive": null, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/createDirectory params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_write_file_params_round_trip_with_base64_data() { + let params = FsWriteFileParams { + path: absolute_path("tmp/example.bin"), + data_base64: "AAE=".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.bin"), + "dataBase64": "AAE=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/writeFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_copy_params_round_trip_with_recursive_directory_copy() { + let params = FsCopyParams { + source_path: absolute_path("tmp/source"), + destination_path: absolute_path("tmp/destination"), + recursive: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); + assert_eq!( + value, + json!({ + "sourcePath": absolute_path_string("tmp/source"), + "destinationPath": absolute_path_string("tmp/destination"), + "recursive": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/copy params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_params_round_trip() { + let params = ThreadShellCommandParams { + thread_id: "thr_123".to_string(), + command: "printf 'hello world\\n'".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); + assert_eq!( + value, + json!({ + "threadId": "thr_123", + "command": "printf 'hello world\\n'", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_response_round_trip() { + let response = ThreadShellCommandResponse {}; + + let value = serde_json::to_value(&response).expect("serialize thread/shellCommand response"); + assert_eq!(value, json!({})); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_changed_notification_round_trips() { + let notification = FsChangedNotification { + watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), + changed_paths: vec![ + absolute_path("tmp/repo/.git/HEAD"), + absolute_path("tmp/repo/.git/FETCH_HEAD"), + ], + }; + + let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); + assert_eq!( + value, + json!({ + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": [ + absolute_path_string("tmp/repo/.git/HEAD"), + absolute_path_string("tmp/repo/.git/FETCH_HEAD"), + ], + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/changed notification"); + assert_eq!(decoded, notification); +} + +#[test] +fn command_exec_params_default_optional_streaming_flags() { + let params = serde_json::from_value::(json!({ + "command": ["ls", "-la"], + "timeoutMs": 1000, + "cwd": "/tmp" + })) + .expect("command/exec payload should deserialize"); + + assert_eq!( + params, + CommandExecParams { + command: vec!["ls".to_string(), "-la".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(1000), + cwd: Some(PathBuf::from("/tmp")), + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_timeout() { + let params = CommandExecParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_id: Some("sleep-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processId": "sleep-1", + "disableTimeout": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + "outputBytesCap": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_round_trips_without_sandbox_policy() { + let params = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize process/spawn params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "env": null, + "size": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_distinguish_omitted_null_and_value_limits() { + let base = json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + }); + + let expected_omitted = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + let decoded = + serde_json::from_value::(base).expect("deserialize omitted limits"); + assert_eq!(decoded, expected_omitted); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": null, + "timeoutMs": null, + })) + .expect("deserialize disabled limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(None), + timeout_ms: Some(None), + ..expected_omitted.clone() + } + ); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": 123, + "timeoutMs": 456, + })) + .expect("deserialize explicit limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(Some(123)), + timeout_ms: Some(Some(456)), + ..expected_omitted + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_output_cap() { + let params = CommandExecParams { + command: vec!["yes".to_string()], + process_id: Some("yes-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["yes"], + "processId": "yes-1", + "streamStdoutStderr": true, + "outputBytesCap": null, + "disableOutputCap": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trips_env_overrides_and_unsets() { + let params = CommandExecParams { + command: vec!["printenv".to_string(), "FOO".to_string()], + process_id: Some("env-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ("FOO".to_string(), Some("override".to_string())), + ("BAR".to_string(), Some("added".to_string())), + ("BAZ".to_string(), None), + ])), + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["printenv", "FOO"], + "processId": "env-1", + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": { + "FOO": "override", + "BAR": "added", + "BAZ": null, + }, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_write_round_trips_close_only_payload() { + let params = CommandExecWriteParams { + process_id: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); + assert_eq!( + value, + json!({ + "processId": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_terminate_round_trips() { + let params = CommandExecTerminateParams { + process_id: "proc-8".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); + assert_eq!( + value, + json!({ + "processId": "proc-8", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trip_with_size() { + let params = CommandExecParams { + command: vec!["top".to_string()], + process_id: Some("pty-1".to_string()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 40, + cols: 120, + }), + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["top"], + "processId": "pty-1", + "tty": true, + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": { + "rows": 40, + "cols": 120, + }, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_resize_round_trips() { + let params = CommandExecResizeParams { + process_id: "proc-9".to_string(), + size: CommandExecTerminalSize { + rows: 50, + cols: 160, + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); + assert_eq!( + value, + json!({ + "processId": "proc-9", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_output_delta_round_trips() { + let notification = CommandExecOutputDeltaNotification { + process_id: "proc-1".to_string(), + stream: CommandExecOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize command/exec/outputDelta notification"); + assert_eq!( + value, + json!({ + "processId": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn process_control_params_round_trip() { + let write = ProcessWriteStdinParams { + process_handle: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + let value = serde_json::to_value(&write).expect("serialize process/writeStdin params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/writeStdin params"); + assert_eq!(decoded, write); + + let resize = ProcessResizePtyParams { + process_handle: "proc-7".to_string(), + size: ProcessTerminalSize { + rows: 50, + cols: 160, + }, + }; + let value = serde_json::to_value(&resize).expect("serialize process/resizePty params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/resizePty params"); + assert_eq!(decoded, resize); + + let kill = ProcessKillParams { + process_handle: "proc-7".to_string(), + }; + let value = serde_json::to_value(&kill).expect("serialize process/kill params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + }) + ); + let decoded = + serde_json::from_value::(value).expect("deserialize process/kill"); + assert_eq!(decoded, kill); +} + +#[test] +fn process_notifications_round_trip() { + let delta = ProcessOutputDeltaNotification { + process_handle: "proc-1".to_string(), + stream: ProcessOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + let value = serde_json::to_value(&delta).expect("serialize process/outputDelta"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/outputDelta"); + assert_eq!(decoded, delta); + + let exited = ProcessExitedNotification { + process_handle: "proc-1".to_string(), + exit_code: 0, + stdout: "out".to_string(), + stdout_cap_reached: false, + stderr: "err".to_string(), + stderr_cap_reached: true, + }; + let value = serde_json::to_value(&exited).expect("serialize process/exited"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "exitCode": 0, + "stdout": "out", + "stdoutCapReached": false, + "stderr": "err", + "stderrCapReached": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/exited"); + assert_eq!(decoded, exited); +} + +#[test] +fn command_execution_output_delta_round_trips() { + let notification = CommandExecutionOutputDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + delta: "\u{fffd}a\n".to_string(), + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize item/commandExecution/outputDelta notification"); + assert_eq!( + value, + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "item-1", + "delta": "\u{fffd}a\n", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn sandbox_policy_round_trips_external_sandbox_network_access() { + let v2_policy = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: CoreNetworkAccess::Enabled, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_round_trips_read_only_network_access() { + let v2_policy = SandboxPolicy::ReadOnly { + network_access: true, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: true, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_round_trips_request_permissions_flag() { + let v2_policy = AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }) + ); + + let back_to_v2 = AskForApproval::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() { + let decoded = serde_json::from_value::(serde_json::json!({ + "granular": { + "sandbox_approval": true, + "rules": false, + "mcp_elicitations": true, + } + })) + .expect("granular approval policy should deserialize"); + + assert_eq!( + decoded, + AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + } + ); +} + +#[test] +fn ask_for_approval_granular_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }); + + assert_eq!(reason, Some("askForApproval.granular")); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::OnRequest,), + None + ); +} + +#[test] +fn profile_v2_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_nested_profile_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_nested_profile_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { + allowed_approval_policies: Some(vec![AskForApproval::Granular { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + feature_requirements: None, + hooks: None, + enforce_residency: None, + network: None, + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadStart { + request_id: crate::RequestId::Integer(1), + params: ThreadStartParams { + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadResume { + request_id: crate::RequestId::Integer(2), + params: ThreadResumeParams { + thread_id: "thr_123".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadFork { + request_id: crate::RequestId::Integer(3), + params: ThreadForkParams { + thread_id: "thr_456".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::TurnStart { + request_id: crate::RequestId::Integer(4), + params: TurnStartParams { + thread_id: "thr_123".to_string(), + input: Vec::new(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn mcp_server_elicitation_response_round_trips_rmcp_result() { + let rmcp_result = rmcp::model::CreateElicitationResult { + action: rmcp::model::ElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + }; + + let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); + assert_eq!( + v2_response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + meta: None, + } + ); + assert_eq!( + rmcp::model::CreateElicitationResult::from(v2_response), + rmcp_result + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_url_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + }) + .expect("URL request should convert"); + + assert_eq!( + request, + McpServerElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_form_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + }), + }) + .expect("form request should convert"); + + let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + })) + .expect("expected schema should deserialize"); + + assert_eq!( + request, + McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: expected_schema, + } + ); +} + +#[test] +fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { + let schema: McpElicitationSchema = serde_json::from_value(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "title": "Email", + "description": "Work email address", + "format": "email", + "default": "dev@example.com", + }, + "count": { + "type": "integer", + "title": "Count", + "description": "How many items to create", + "minimum": 1, + "maximum": 5, + "default": 3, + }, + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action", + "default": true, + }, + "legacyChoice": { + "type": "string", + "title": "Action", + "description": "Legacy titled enum form", + "enum": ["allow", "deny"], + "enumNames": ["Allow", "Deny"], + "default": "allow", + }, + }, + "required": ["email", "confirmed"], + })) + .expect("schema should deserialize"); + + assert_eq!( + schema, + McpElicitationSchema { + schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), + type_: McpElicitationObjectType::Object, + properties: BTreeMap::from([ + ( + "confirmed".to_string(), + McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { + type_: McpElicitationBooleanType::Boolean, + title: Some("Confirm".to_string()), + description: Some("Approve the pending action".to_string()), + default: Some(true), + }), + ), + ( + "count".to_string(), + McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { + type_: McpElicitationNumberType::Integer, + title: Some("Count".to_string()), + description: Some("How many items to create".to_string()), + minimum: Some(1.0), + maximum: Some(5.0), + default: Some(3.0), + }), + ), + ( + "email".to_string(), + McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { + type_: McpElicitationStringType::String, + title: Some("Email".to_string()), + description: Some("Work email address".to_string()), + min_length: None, + max_length: None, + format: Some(McpElicitationStringFormat::Email), + default: Some("dev@example.com".to_string()), + }), + ), + ( + "legacyChoice".to_string(), + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( + McpElicitationLegacyTitledEnumSchema { + type_: McpElicitationStringType::String, + title: Some("Action".to_string()), + description: Some("Legacy titled enum form".to_string()), + enum_: vec!["allow".to_string(), "deny".to_string()], + enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), + default: Some("allow".to_string()), + }, + )), + ), + ]), + required: Some(vec!["email".to_string(), "confirmed".to_string()]), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_rejects_null_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: Some(json!({ + "persist": "session", + })), + message: "Allow this request?".to_string(), + requested_schema: JsonValue::Null, + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "object", + } + }, + }), + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_response_serializes_nullable_content() { + let response = McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + meta: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "action": "decline", + "content": null, + "_meta": null, + }) + ); +} + +#[test] +fn sandbox_policy_round_trips_workspace_write_access() { + let v2_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { + let policy = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "fullAccess" + }, + "networkAccess": true + })) + .expect("read-only policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::ReadOnly { + network_access: true + } + ); +} + +#[test] +fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "readOnlyAccess": { + "type": "fullAccess" + }, + "networkAccess": true, + "excludeTmpdirEnvVar": true, + "excludeSlashTmp": true + })) + .expect("workspace-write policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![absolute_path("/workspace")], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); +} + +#[test] +fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { + let err = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + } + })) + .expect_err("read-only policy should reject removed restricted access field"); + assert!(err.to_string().contains("readOnly.access")); +} + +#[test] +fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { + let err = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [], + "readOnlyAccess": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + }, + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + })) + .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); + assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); +} + +#[test] +fn automatic_approval_review_deserializes_aborted_status() { + let review: GuardianApprovalReview = serde_json::from_value(json!({ + "status": "aborted", + "riskLevel": null, + "userAuthorization": null, + "rationale": null + })) + .expect("aborted automatic review should deserialize"); + assert_eq!( + review, + GuardianApprovalReview { + status: GuardianApprovalReviewStatus::Aborted, + risk_level: None, + user_authorization: None, + rationale: None, + } + ); +} + +#[test] +fn guardian_approval_review_action_round_trips_command_shape() { + let value = json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/example.sqlite", + "cwd": absolute_path_string("tmp"), + }); + let action: GuardianApprovalReviewAction = + serde_json::from_value(value.clone()).expect("guardian review action"); + + assert_eq!( + action, + GuardianApprovalReviewAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: absolute_path("tmp"), + } + ); + assert_eq!( + serde_json::to_value(&action).expect("serialize guardian review action"), + value + ); +} + +#[test] +fn network_requirements_deserializes_legacy_fields() { + let requirements: NetworkRequirements = serde_json::from_value(json!({ + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "allowUnixSockets": ["/tmp/proxy.sock"] + })) + .expect("legacy network requirements should deserialize"); + + assert_eq!( + requirements, + NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: None, + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: None, + } + ); +} + +#[test] +fn network_requirements_serializes_canonical_and_legacy_fields() { + let requirements = NetworkRequirements { + enabled: Some(true), + http_port: Some(8080), + socks_port: Some(1080), + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(true), + domains: Some(BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow), + ( + "blocked.example.com".to_string(), + NetworkDomainPermission::Deny, + ), + ])), + managed_allowed_domains_only: Some(true), + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: Some(BTreeMap::from([ + ( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + ), + ])), + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: Some(true), + }; + + assert_eq!( + serde_json::to_value(requirements).expect("network requirements should serialize"), + json!({ + "enabled": true, + "httpPort": 8080, + "socksPort": 1080, + "allowUpstreamProxy": false, + "dangerouslyAllowNonLoopbackProxy": false, + "dangerouslyAllowAllUnixSockets": true, + "domains": { + "api.openai.com": "allow", + "blocked.example.com": "deny" + }, + "managedAllowedDomainsOnly": true, + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "unixSockets": { + "/tmp/ignored.sock": "none", + "/tmp/proxy.sock": "allow" + }, + "allowUnixSockets": ["/tmp/proxy.sock"], + "allowLocalBinding": true + }) + ); +} + +#[test] +fn core_turn_item_into_thread_item_converts_supported_variants() { + let user_item = TurnItem::UserMessage(UserMessageItem { + id: "user-1".to_string(), + content: vec![ + CoreUserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + }, + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + CoreUserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + CoreUserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + computer_use_native_app_bundle_id: None, + }, + ], + }); + + assert_eq!( + ThreadItem::from(user_item), + ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + UserInput::Image { + url: "https://example.com/image.png".to_string(), + }, + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + UserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + computer_use_native_app_bundle_id: None, + }, + ], + } + ); + + let agent_item = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-1".to_string(), + content: vec![ + AgentMessageContent::Text { + text: "Hello ".to_string(), + }, + AgentMessageContent::Text { + text: "world".to_string(), + }, + ], + phase: None, + memory_citation: None, + }); + + assert_eq!( + ThreadItem::from(agent_item), + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "Hello world".to_string(), + phase: None, + memory_citation: None, + } + ); + + let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-2".to_string(), + content: vec![AgentMessageContent::Text { + text: "final".to_string(), + }], + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(CoreMemoryCitation { + entries: vec![CoreMemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + rollout_ids: vec!["rollout-1".to_string()], + }), + }); + + assert_eq!( + ThreadItem::from(agent_item_with_phase), + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: "final".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(MemoryCitation { + entries: vec![MemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + thread_ids: vec!["rollout-1".to_string()], + }), + } + ); + + let reasoning_item = TurnItem::Reasoning(ReasoningItem { + id: "reasoning-1".to_string(), + summary_text: vec!["line one".to_string(), "line two".to_string()], + raw_content: vec![], + }); + + assert_eq!( + ThreadItem::from(reasoning_item), + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["line one".to_string(), "line two".to_string()], + content: vec![], + } + ); + + let search_item = TurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: CoreWebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }, + }); + + assert_eq!( + ThreadItem::from(search_item), + ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + } + ); + + let image_view_item = TurnItem::ImageView(ImageViewItem { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + }); + + assert_eq!( + ThreadItem::from(image_view_item), + ThreadItem::ImageView { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + } + ); + + let file_change_item = TurnItem::FileChange(FileChangeItem { + id: "patch-1".to_string(), + changes: [( + PathBuf::from("README.md"), + codex_protocol::protocol::FileChange::Add { + content: "hello\n".to_string(), + }, + )] + .into_iter() + .collect(), + status: Some(codex_protocol::protocol::PatchApplyStatus::Completed), + auto_approved: None, + stdout: Some("Done!".to_string()), + stderr: Some(String::new()), + }); + + assert_eq!( + ThreadItem::from(file_change_item), + ThreadItem::FileChange { + id: "patch-1".to_string(), + changes: vec![FileUpdateChange { + path: "README.md".to_string(), + kind: PatchChangeKind::Add, + diff: "hello\n".to_string(), + }], + status: PatchApplyStatus::Completed, + } + ); + + let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + status: CoreMcpToolCallStatus::InProgress, + result: None, + error: None, + duration: None, + }); + + assert_eq!( + ThreadItem::from(mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::InProgress, + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + result: None, + error: None, + duration_ms: None, + } + ); + + let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + status: CoreMcpToolCallStatus::Completed, + result: Some(CallToolResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + is_error: Some(false), + meta: Some(json!({"trace": "1"})), + }), + error: None, + duration: Some(Duration::from_millis(42)), + }); + + assert_eq!( + ThreadItem::from(completed_mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::Completed, + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + result: Some(Box::new(McpToolCallResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + meta: Some(json!({"trace": "1"})), + })), + error: None, + duration_ms: Some(42), + } + ); +} + +#[test] +fn skills_list_params_serialization_uses_force_reload() { + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: Vec::new(), + force_reload: false, + per_cwd_extra_user_roots: None, + }) + .unwrap(), + json!({ + "perCwdExtraUserRoots": null, + }), + ); + + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: vec![PathBuf::from("/repo")], + force_reload: true, + per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { + cwd: PathBuf::from("/repo"), + extra_user_roots: vec![PathBuf::from("/shared/skills"), PathBuf::from("/tmp/x")], + }]), + }) + .unwrap(), + json!({ + "cwds": ["/repo"], + "forceReload": true, + "perCwdExtraUserRoots": [ + { + "cwd": "/repo", + "extraUserRoots": ["/shared/skills", "/tmp/x"], + } + ], + }), + ); +} + +#[test] +fn plugin_source_serializes_local_git_and_remote_variants() { + let local_path = if cfg!(windows) { + r"C:\plugins\linear" + } else { + "/plugins/linear" + }; + let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); + let local_path_json = local_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), + json!({ + "type": "local", + "path": local_path_json, + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Git { + url: "https://github.com/openai/example.git".to_string(), + path: Some("plugins/example".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), + }) + .unwrap(), + json!({ + "type": "git", + "url": "https://github.com/openai/example.git", + "path": "plugins/example", + "refName": "main", + "sha": "abc123", + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Remote).unwrap(), + json!({ + "type": "remote", + }), + ); +} + +#[test] +fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: None, + sparse_paths: None, + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": null, + "sparsePaths": null, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: Some("main".to_string()), + sparse_paths: Some(vec!["plugins/foo".to_string()]), + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": "main", + "sparsePaths": ["plugins/foo"], + }), + ); +} + +#[test] +fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: None, + }) + .unwrap(), + json!({ + "marketplaceName": null, + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + MarketplaceUpgradeParams { + marketplace_name: None, + }, + ); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: Some("debug".to_string()), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + }), + ); +} + +#[test] +fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { + assert_eq!( + serde_json::to_value(PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: None, + interface: None, + plugins: Vec::new(), + }) + .unwrap(), + json!({ + "name": "openai-curated", + "path": null, + "interface": null, + "plugins": [], + }), + ); +} + +#[test] +fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { + let composer_icon = if cfg!(windows) { + r"C:\plugins\linear\icon.png" + } else { + "/plugins/linear/icon.png" + }; + let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); + let composer_icon_json = composer_icon.as_path().display().to_string(); + + let interface = PluginInterface { + display_name: Some("Linear".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Productivity".to_string()), + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some(composer_icon), + composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), + logo: None, + logo_url: Some("https://example.com/linear/logo.png".to_string()), + screenshots: Vec::new(), + screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], + }; + + assert_eq!( + serde_json::to_value(interface).unwrap(), + json!({ + "displayName": "Linear", + "shortDescription": null, + "longDescription": null, + "developerName": null, + "category": "Productivity", + "capabilities": [], + "websiteUrl": null, + "privacyPolicyUrl": null, + "termsOfServiceUrl": null, + "defaultPrompt": null, + "brandColor": null, + "composerIcon": composer_icon_json, + "composerIconUrl": "https://example.com/linear/icon.png", + "logo": null, + "logoUrl": "https://example.com/linear/logo.png", + "screenshots": [], + "screenshotUrls": ["https://example.com/linear/screenshot.png"], + }), + ); +} + +#[test] +fn plugin_list_params_ignore_removed_force_remote_sync_field() { + assert_eq!( + serde_json::from_value::(json!({ + "cwds": null, + "forceRemoteSync": true, + })) + .unwrap(), + PluginListParams { cwds: None }, + ); +} + +#[test] +fn plugin_read_params_serialization_uses_install_source_fields() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + })) + .unwrap(), + PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_install_params_serialization_omits_force_remote_sync() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginInstallParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { + assert_eq!( + serde_json::to_value(PluginSkillReadParams { + remote_marketplace_name: "chatgpt-global".to_string(), + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + skill_name: "plan-work".to_string(), + }) + .unwrap(), + json!({ + "remoteMarketplaceName": "chatgpt-global", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "skillName": "plan-work", + }), + ); +} + +#[test] +fn plugin_share_params_and_response_serialization_use_camel_case_fields() { + let plugin_path = if cfg!(windows) { + r"C:\plugins\gmail" + } else { + "/plugins/gmail" + }; + let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); + let plugin_path_json = plugin_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path: plugin_path.clone(), + remote_plugin_id: None, + discoverability: None, + share_targets: None, + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": null, + "discoverability": null, + "shareTargets": null, + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path, + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),), + discoverability: Some(PluginShareDiscoverability::Private), + share_targets: Some(vec![ + PluginShareTarget { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + PluginShareTarget { + principal_type: PluginSharePrincipalType::Workspace, + principal_id: "workspace-1".to_string(), + }, + ]), + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + { + "principalType": "workspace", + "principalId": "workspace-1", + }, + ], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveResponse { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_url: String::new(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareUrl": "", + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_targets: vec![PluginShareTarget { + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }], + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareTargets": [{ + "principalType": "group", + "principalId": "group-1", + }], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + }) + .unwrap(), + json!({ + "principals": [{ + "principalType": "user", + "principalId": "user-1", + "name": "Gavin", + }], + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + PluginShareListParams {}, + ); + + assert_eq!( + serde_json::to_value(PluginShareDeleteParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); +} + +#[test] +fn plugin_share_list_response_serializes_share_items() { + assert_eq!( + serde_json::to_value(PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + name: "gmail".to_string(), + source: PluginSource::Remote, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, + }], + }) + .unwrap(), + json!({ + "data": [{ + "plugin": { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "availability": "AVAILABLE", + "interface": null, + "keywords": [], + }, + "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", + "localPluginPath": null, + }], + }), + ); +} + +#[test] +fn plugin_summary_defaults_missing_availability_to_available() { + let summary: PluginSummary = serde_json::from_value(json!({ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "interface": null, + })) + .unwrap(); + + assert_eq!(summary.availability, PluginAvailability::Available); +} + +#[test] +fn plugin_availability_deserializes_enabled_alias() { + let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); + + assert_eq!(availability, PluginAvailability::Available); + assert_eq!( + serde_json::to_value(availability).unwrap(), + json!("AVAILABLE") + ); +} + +#[test] +fn plugin_uninstall_params_serialization_omits_force_remote_sync() { + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "gmail@openai-curated", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "gmail@openai-curated", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }, + ); + + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "plugins~Plugin_gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "plugins~Plugin_gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }, + ); +} + +#[test] +fn marketplace_remove_response_serializes_nullable_installed_root() { + let installed_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap(); + let installed_root_json = installed_root.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: Some(installed_root), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": installed_root_json, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: None, + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": null, + }), + ); +} + +#[test] +fn marketplace_upgrade_response_serializes_camel_case_fields() { + let upgraded_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); + let upgraded_root_json = upgraded_root.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string()], + upgraded_roots: vec![upgraded_root], + errors: vec![MarketplaceUpgradeErrorInfo { + marketplace_name: "broken".to_string(), + message: "failed to clone".to_string(), + }], + }) + .unwrap(), + json!({ + "selectedMarketplaces": ["debug"], + "upgradedRoots": [upgraded_root_json], + "errors": [{ + "marketplaceName": "broken", + "message": "failed to clone", + }], + }), + ); +} + +#[test] +fn codex_error_info_serializes_http_status_code_in_camel_case() { + let value = CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(401), + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "responseTooManyFailedAttempts": { + "httpStatusCode": 401 + } + }) + ); +} + +#[test] +fn codex_error_info_serializes_cyber_policy_in_camel_case() { + assert_eq!( + serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), + json!("cyberPolicy") + ); +} + +#[test] +fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { + let value = CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "activeTurnNotSteerable": { + "turnKind": "review" + } + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_text_and_image_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![ + DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + }, + ], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + }, + { + "type": "inputImage", + "imageUrl": "data:image/png;base64,AAA" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_spec_deserializes_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + "deferLoading": true, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert_eq!( + actual, + DynamicToolSpec { + namespace: None, + name: "lookup_ticket".to_string(), + description: "Fetch a ticket".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string" } + } + }), + defer_loading: true, + } + ); +} + +#[test] +fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": {} + }, + "exposeToContext": false, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert!(actual.defer_loading); +} + +#[test] +fn thread_start_params_preserve_explicit_null_service_tier() { + let params: ThreadStartParams = + serde_json::from_value(json!({ "serviceTier": null })).expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn thread_lifecycle_responses_default_missing_optional_fields() { + let response = json!({ + "thread": { + "id": "thread-id", + "sessionId": "thread-id", + "forkedFromId": null, + "preview": "", + "ephemeral": false, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 1, + "status": { "type": "idle" }, + "path": null, + "cwd": absolute_path_string("tmp"), + "cliVersion": "0.0.0", + "source": "exec", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5", + "modelProvider": "openai", + "serviceTier": null, + "cwd": absolute_path_string("tmp"), + "approvalPolicy": "on-failure", + "approvalsReviewer": "user", + "sandbox": { "type": "dangerFullAccess" }, + "reasoningEffort": null + }); + + let start: ThreadStartResponse = + serde_json::from_value(response.clone()).expect("thread/start response"); + let resume: ThreadResumeResponse = + serde_json::from_value(response.clone()).expect("thread/resume response"); + let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); + + assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(resume.instruction_sources, Vec::::new()); + assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); + assert_eq!(start.active_permission_profile, None); + assert_eq!(resume.active_permission_profile, None); + assert_eq!(fork.active_permission_profile, None); +} + +#[test] +fn turn_start_params_preserve_explicit_null_service_tier() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = TurnStartParams { + thread_id: "thread_123".to_string(), + input: vec![], + responsesapi_client_metadata: None, + environments: None, + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permissions: None, + model: None, + service_tier: None, + effort: None, + summary: None, + output_schema: None, + collaboration_mode: None, + personality: None, + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn turn_start_params_round_trip_environments() { + let cwd = test_absolute_path(); + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], + })) + .expect("params should deserialize"); + + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: cwd.clone(), + }]) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("environments"), + Some(&json!([ + { + "environmentId": "local", + "cwd": cwd + } + ])) + ); +} + +#[test] +fn turn_start_params_preserve_empty_environments() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [], + })) + .expect("params should deserialize"); + + assert_eq!(params.environments, Some(Vec::new())); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized.get("environments"), Some(&json!([]))); +} + +#[test] +fn turn_start_params_treat_null_or_omitted_environments_as_default() { + let null_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": null, + })) + .expect("params should deserialize"); + let omitted_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + })) + .expect("params should deserialize"); + + assert_eq!(null_environments.environments, None); + assert_eq!(omitted_environments.environments, None); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), + None + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), + None + ); +} + +#[test] +fn turn_start_params_reject_relative_environment_cwd() { + let err = serde_json::from_value::(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": "relative" + } + ], + })) + .expect_err("relative environment cwd should fail"); + + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs new file mode 100644 index 0000000000..5f293ff8f6 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -0,0 +1,1152 @@ +use super::ActivePermissionProfile; +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfile; +use super::PermissionProfileSelectionParams; +use super::SandboxMode; +use super::SandboxPolicy; +use super::Thread; +use super::ThreadSource; +use super::Turn; +use super::TurnEnvironmentParams; +use super::shared::v2_enum_from_core; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::Personality; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; +use codex_protocol::protocol::TokenUsage as CoreTokenUsage; +use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadStartSource { + Startup, + Clear, +} + +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolSpec { + #[ts(optional)] + pub namespace: Option, + pub name: String, + pub description: String, + pub input_schema: JsonValue, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub defer_loading: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DynamicToolSpecDe { + namespace: Option, + name: String, + description: String, + input_schema: JsonValue, + defer_loading: Option, + expose_to_context: Option, +} + +impl<'de> Deserialize<'de> for DynamicToolSpec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let DynamicToolSpecDe { + namespace, + name, + description, + input_schema, + defer_loading, + expose_to_context, + } = DynamicToolSpecDe::deserialize(deserializer)?; + + Ok(Self { + namespace, + name, + description, + input_schema, + defer_loading: defer_loading + .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), + }) + } +} + +// === Threads, Turns, and Items === +// Thread APIs +#[derive( + Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartParams { + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for this thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported turn/thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub service_name: Option, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + #[ts(optional = nullable)] + pub ephemeral: Option, + #[ts(optional = nullable)] + pub session_start_source: Option, + /// Optional client-supplied analytics source classification for this thread. + #[ts(optional = nullable)] + pub thread_source: Option, + /// Optional sticky environments for this thread. + /// + /// Omitted selects the default environment when environment access is + /// enabled. Empty disables environment access for turns that do not + /// provide a turn override. Non-empty selects the first environment as the + /// current turn environment. + #[experimental("thread/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + #[experimental("thread/start.dynamicTools")] + #[ts(optional = nullable)] + pub dynamic_tools: Option>, + /// Test-only experimental field used to validate experimental gating and + /// schema filtering behavior in a stable way. + #[experimental("thread/start.mockExperimentalField")] + #[ts(optional = nullable)] + pub mock_experimental_field: Option, + /// If true, opt into emitting raw Responses API items on the event stream. + /// This is for internal use only (e.g. Codex Cloud). + #[experimental("thread/start.experimentalRawEvents")] + #[serde(default)] + pub experimental_raw_events: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/start.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodParams { + /// Test-only payload field. + #[ts(optional = nullable)] + pub value: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodResponse { + /// Echoes the input `value`. + pub echoed: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/start.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/start.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are three ways to resume a thread: +/// 1. By thread_id: load the thread from disk by thread_id and resume it. +/// 2. By history: instantiate the thread from memory and resume it. +/// 3. By path: load the thread from disk by path and resume it. +/// +/// The precedence is: history > path > thread_id. +/// If using history or path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadResumeParams { + pub thread_id: String, + + /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. + /// If specified, the thread will be resumed with the provided history + /// instead of loaded from disk. + #[experimental("thread/resume.history")] + #[ts(optional = nullable)] + pub history: Option>, + + /// [UNSTABLE] Specify the rollout path to resume from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/resume.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the resumed thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the resumed thread. Cannot be combined + /// with `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/resume.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + /// When true, return only thread metadata and live-resume state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after resuming. + #[experimental("thread/resume.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/resume.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/resume.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/resume.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are two ways to fork a thread: +/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. +/// 2. By path: load the thread from disk by path and fork it into a new thread. +/// +/// If using path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadForkParams { + pub thread_id: String, + + /// [UNSTABLE] Specify the rollout path to fork from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/fork.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the forked thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the forked thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/fork.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ephemeral: bool, + /// Optional client-supplied analytics source classification for this forked thread. + #[ts(optional = nullable)] + pub thread_source: Option, + /// When true, return only thread metadata and live fork state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after forking. + #[experimental("thread/fork.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/fork.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadForkResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/fork.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/fork.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeResponse { + pub status: ThreadUnsubscribeStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadUnsubscribeStatus { + NotLoaded, + NotSubscribed, + Unsubscribed, +} + +/// Parameters for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be incremented. + pub thread_id: String, +} + +/// Response for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationResponse { + /// Current out-of-band elicitation count after the increment. + pub count: u64, + /// Whether timeout accounting is paused after applying the increment. + pub paused: bool, +} + +/// Parameters for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be decremented. + pub thread_id: String, +} + +/// Response for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationResponse { + /// Current out-of-band elicitation count after the decrement. + pub count: u64, + /// Whether timeout accounting remains paused after applying the decrement. + pub paused: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameParams { + pub thread_id: String, + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameResponse {} + +v2_enum_from_core! { + pub enum ThreadGoalStatus from CoreThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoal { + pub thread_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + #[ts(type = "number | null")] + pub token_budget: Option, + #[ts(type = "number")] + pub tokens_used: i64, + #[ts(type = "number")] + pub time_used_seconds: i64, + #[ts(type = "number")] + pub created_at: i64, + #[ts(type = "number")] + pub updated_at: i64, +} + +impl From for ThreadGoal { + fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { + Self { + thread_id: value.thread_id.to_string(), + objective: value.objective, + status: value.status.into(), + token_budget: value.token_budget, + tokens_used: value.tokens_used, + time_used_seconds: value.time_used_seconds, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub objective: Option, + #[ts(optional = nullable)] + pub status: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable, type = "number | null")] + pub token_budget: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetResponse { + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetResponse { + pub goal: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearResponse { + pub cleared: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateParams { + pub thread_id: String, + /// Patch the stored Git metadata for this thread. + /// Omit a field to leave it unchanged, set it to `null` to clear it, or + /// provide a string to replace the stored value. + #[ts(optional = nullable)] + pub git_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataGitInfoUpdateParams { + /// Omit to leave the stored commit unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub sha: Option>, + /// Omit to leave the stored branch unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub branch: Option>, + /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub origin_url: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +pub enum ThreadMemoryMode { + Enabled, + Disabled, +} + +impl ThreadMemoryMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + } + } + + pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode { + match self { + Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled, + Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetParams { + pub thread_id: String, + pub mode: ThreadMemoryMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryResetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandParams { + pub thread_id: String, + /// Shell command string evaluated by the thread's configured shell. + /// Unlike `command/exec`, this intentionally preserves shell syntax + /// such as pipes, redirects, and quoting. This runs unsandboxed with full + /// access rather than inheriting the thread sandbox policy. + pub command: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionParams { + pub thread_id: String, + /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. + pub event: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackParams { + pub thread_id: String, + /// The number of turns to drop from the end of the thread. Must be >= 1. + /// + /// This only modifies the thread's history and does not revert local file changes + /// that have been made by the agent. Clients are responsible for reverting these changes. + pub num_turns: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackResponse { + /// The updated thread after applying the rollback, with `turns` populated. + /// + /// The ThreadItems stored in each Turn are lossy since we explicitly do not + /// persist all agent interactions, such as command executions. This is the same + /// behavior as `thread/resume`. + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional sort key; defaults to created_at. + #[ts(optional = nullable)] + pub sort_key: Option, + /// Optional sort direction; defaults to descending (newest first). + #[ts(optional = nullable)] + pub sort_direction: Option, + /// Optional provider filter; when set, only sessions recorded under these + /// providers are returned. When present but empty, includes all providers. + #[ts(optional = nullable)] + pub model_providers: Option>, + /// Optional source filter; when set, only sessions from these source kinds + /// are returned. When omitted or empty, defaults to interactive sources. + #[ts(optional = nullable)] + pub source_kinds: Option>, + /// Optional archived filter; when set to true, only archived threads are returned. + /// If false or null, only non-archived threads are returned. + #[ts(optional = nullable)] + pub archived: Option, + /// Optional cwd filter or filters; when set, only threads whose session cwd + /// exactly matches one of these paths are returned. + #[ts(optional = nullable, type = "string | Array | null")] + pub cwd: Option, + /// If true, return from the state DB without scanning JSONL rollouts to + /// repair thread metadata. Omitted or false preserves scan-and-repair + /// behavior. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub use_state_db_only: bool, + /// Optional substring filter for the extracted thread title. + #[ts(optional = nullable)] + pub search_term: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ThreadListCwdFilter { + One(String), + Many(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadSourceKind { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + VsCode, + Exec, + AppServer, + SubAgent, + SubAgentReview, + SubAgentCompact, + SubAgentThreadSpawn, + SubAgentOther, + Unknown, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SortDirection { + Asc, + Desc, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one thread. + /// Use it with the opposite `sortDirection`; for timestamp sorts it anchors + /// at the start of the page timestamp so same-second updates are not skipped. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to no limit. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListResponse { + /// Thread ids for sessions currently loaded in memory. + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadStatus { + NotLoaded, + Idle, + SystemError, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Active { + active_flags: Vec, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadActiveFlag { + WaitingOnApproval, + WaitingOnUserInput, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadParams { + pub thread_id: String, + /// When true, include turns and their items from rollout history. + #[serde(default)] + pub include_turns: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsParams { + pub thread_id: String, + /// Raw Responses API items to append to the thread's model-visible history. + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListParams { + pub thread_id: String, + /// Opaque cursor to pass to the next call to continue after the last turn. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last turn. + /// if None, there are no more turns to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one turn. + /// Use it with the opposite `sortDirection` to include the anchor turn again + /// and catch updates to that turn. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsageUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub token_usage: ThreadTokenUsage, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsage { + pub total: TokenUsageBreakdown, + pub last: TokenUsageBreakdown, + // TODO(aibrahim): make this not optional + #[ts(type = "number | null")] + pub model_context_window: Option, +} + +impl From for ThreadTokenUsage { + fn from(value: CoreTokenUsageInfo) -> Self { + Self { + total: value.total_token_usage.into(), + last: value.last_token_usage.into(), + model_context_window: value.model_context_window, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TokenUsageBreakdown { + #[ts(type = "number")] + pub total_tokens: i64, + #[ts(type = "number")] + pub input_tokens: i64, + #[ts(type = "number")] + pub cached_input_tokens: i64, + #[ts(type = "number")] + pub output_tokens: i64, + #[ts(type = "number")] + pub reasoning_output_tokens: i64, +} + +impl From for TokenUsageBreakdown { + fn from(value: CoreTokenUsage) -> Self { + Self { + total_tokens: value.total_tokens, + input_tokens: value.input_tokens, + cached_input_tokens: value.cached_input_tokens, + output_tokens: value.output_tokens, + reasoning_output_tokens: value.reasoning_output_tokens, + } + } +} + +// Thread/Turn lifecycle notifications and item progress events +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartedNotification { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStatusChangedNotification { + pub thread_id: String, + pub status: ThreadStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadClosedNotification { + pub thread_id: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadNameUpdatedNotification { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalUpdatedNotification { + pub thread_id: String, + pub turn_id: Option, + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearedNotification { + pub thread_id: String, +} + +/// Deprecated: Use `ContextCompaction` item type instead. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ContextCompactedNotification { + pub thread_id: String, + pub turn_id: String, +} 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 new file mode 100644 index 0000000000..f0c518adf8 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -0,0 +1,196 @@ +use super::CodexErrorInfo; +use super::ThreadItem; +use super::ThreadStatus; +use super::TurnStatus; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::ThreadSource as CoreThreadSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +#[derive(Default)] +pub enum SessionSource { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + #[default] + VsCode, + Exec, + AppServer, + Custom(String), + SubAgent(CoreSubAgentSource), + #[serde(other)] + Unknown, +} + +impl From for SessionSource { + fn from(value: CoreSessionSource) -> Self { + match value { + CoreSessionSource::Cli => SessionSource::Cli, + CoreSessionSource::VSCode => SessionSource::VsCode, + CoreSessionSource::Exec => SessionSource::Exec, + CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::Custom(source) => SessionSource::Custom(source), + // We do not want to render those at the app-server level. + CoreSessionSource::Internal(_) => SessionSource::Unknown, + CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), + CoreSessionSource::Unknown => SessionSource::Unknown, + } + } +} + +impl From for CoreSessionSource { + fn from(value: SessionSource) -> Self { + match value { + SessionSource::Cli => CoreSessionSource::Cli, + SessionSource::VsCode => CoreSessionSource::VSCode, + SessionSource::Exec => CoreSessionSource::Exec, + SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Custom(source) => CoreSessionSource::Custom(source), + SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), + SessionSource::Unknown => CoreSessionSource::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum ThreadSource { + User, + Subagent, + MemoryConsolidation, +} + +impl From for ThreadSource { + fn from(value: CoreThreadSource) -> Self { + match value { + CoreThreadSource::User => ThreadSource::User, + CoreThreadSource::Subagent => ThreadSource::Subagent, + CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation, + } + } +} + +impl From for CoreThreadSource { + fn from(value: ThreadSource) -> Self { + match value { + ThreadSource::User => CoreThreadSource::User, + ThreadSource::Subagent => CoreThreadSource::Subagent, + ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, + /// Session id shared by threads that belong to the same session tree. + pub session_id: String, + /// Source thread id when this thread was created by forking another thread. + pub forked_from_id: Option, + /// Usually the first user message in the thread, if available. + pub preview: String, + /// Whether the thread is ephemeral and should not be materialized on disk. + pub ephemeral: bool, + /// Model provider used for this thread (for example, 'openai'). + pub model_provider: String, + /// Unix timestamp (in seconds) when the thread was created. + #[ts(type = "number")] + pub created_at: i64, + /// Unix timestamp (in seconds) when the thread was last updated. + #[ts(type = "number")] + pub updated_at: i64, + /// Current runtime status for the thread. + pub status: ThreadStatus, + /// [UNSTABLE] Path to the thread on disk. + pub path: Option, + /// Working directory captured for the thread. + pub cwd: AbsolutePathBuf, + /// Version of the CLI that created the thread. + pub cli_version: String, + /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, + /// Optional Git metadata captured when the thread was created. + pub git_info: Option, + /// Optional user-facing thread title. + pub name: Option, + /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` + /// (when `includeTurns` is true) responses. + /// For all other responses and notifications returning a Thread, + /// the turns field will be an empty list. + pub turns: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Turn { + pub id: String, + /// Thread items currently included in this turn payload. + pub items: Vec, + /// Describes how much of `items` has been loaded for this turn. + #[serde(default)] + pub items_view: TurnItemsView, + pub status: TurnStatus, + /// Only populated when the Turn's status is failed. + pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnItemsView { + /// `items` was not loaded for this turn. The field is intentionally empty. + NotLoaded, + /// `items` contains only a display summary for this turn. + Summary, + /// `items` contains every ThreadItem available from persisted app-server history for this turn. + #[default] + Full, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +#[error("{message}")] +pub struct TurnError { + pub message: String, + pub codex_error_info: Option, + #[serde(default)] + pub additional_details: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs new file mode 100644 index 0000000000..6c5ed1ed29 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -0,0 +1,407 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfileSelectionParams; +use super::SandboxPolicy; +use super::Turn; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; +use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::user_input::ByteRange as CoreByteRange; +use codex_protocol::user_input::TextElement as CoreTextElement; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnStatus { + Completed, + Interrupted, + Failed, + InProgress, +} + +// Turn APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/start.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Optional turn-scoped environments. + /// + /// Omitted uses the thread sticky environments. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. + #[experimental("turn/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + /// Override the working directory for this turn and subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for this turn and subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this turn and + /// subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for this turn and subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile for this turn and subsequent turns. + /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` + /// for supported turn adjustments instead of replacing the full + /// permissions profile. + #[experimental("turn/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for this turn and subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for this turn and subsequent turns. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for this turn and subsequent turns. + #[ts(optional = nullable)] + pub effort: Option, + /// Override the reasoning summary for this turn and subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// Override the personality for this turn and subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, + /// Optional JSON Schema used to constrain the final assistant message for + /// this turn. + #[ts(optional = nullable)] + pub output_schema: Option, + + /// EXPERIMENTAL - Set a pre-set collaboration mode. + /// Takes precedence over model, reasoning_effort, and developer instructions if set. + /// + /// For `collaboration_mode.settings.developer_instructions`, `null` means + /// "use the built-in instructions for the selected mode". + #[experimental("turn/start.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartResponse { + pub turn: Turn, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/steer.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Required active turn id precondition. The request fails when it does not + /// match the currently active turn. + pub expected_turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerResponse { + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptParams { + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptResponse {} + +// User input types +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ByteRange { + pub start: usize, + pub end: usize, +} + +impl From for ByteRange { + fn from(value: CoreByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +impl From for CoreByteRange { + fn from(value: ByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextElement { + /// Byte range in the parent `text` buffer that this element occupies. + pub byte_range: ByteRange, + /// Optional human-readable placeholder for the element, displayed in the UI. + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } +} + +impl From for TextElement { + fn from(value: CoreTextElement) -> Self { + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) + } +} + +impl From for CoreTextElement { + fn from(value: TextElement) -> Self { + Self::new(value.byte_range.into(), value.placeholder) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum UserInput { + Text { + text: String, + /// UI-defined spans within `text` used to render or persist special elements. + #[serde(default)] + text_elements: Vec, + }, + Image { + url: String, + }, + LocalImage { + path: PathBuf, + }, + Skill { + name: String, + path: PathBuf, + }, + Mention { + name: String, + path: String, + #[serde(default)] + computer_use_native_app_bundle_id: Option, + }, +} + +impl UserInput { + pub fn into_core(self) -> CoreUserInput { + match self { + UserInput::Text { + text, + text_elements, + } => CoreUserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + UserInput::Image { url } => CoreUserInput::Image { image_url: url }, + UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, + UserInput::Mention { + name, + path, + computer_use_native_app_bundle_id, + } => CoreUserInput::Mention { + name, + path, + computer_use_native_app_bundle_id, + }, + } + } +} + +impl From for UserInput { + fn from(value: CoreUserInput) -> Self { + match value { + CoreUserInput::Text { + text, + text_elements, + } => UserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, + CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, + CoreUserInput::Mention { + name, + path, + computer_use_native_app_bundle_id, + } => UserInput::Mention { + name, + path, + computer_use_native_app_bundle_id, + }, + _ => unreachable!("unsupported user input variant"), + } + } +} + +impl UserInput { + pub fn text_char_count(&self) -> usize { + match self { + UserInput::Text { text, .. } => text.chars().count(), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } => 0, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Usage { + pub input_tokens: i32, + pub cached_input_tokens: i32, + pub output_tokens: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnCompletedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification that the turn-level unified diff has changed. +/// Contains the latest aggregated diff across all file changes in the turn. +pub struct TurnDiffUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub explanation: Option, + pub plan: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanStep { + pub step: String, + pub status: TurnPlanStepStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnPlanStepStatus { + Pending, + InProgress, + Completed, +} + +impl From for TurnPlanStep { + fn from(value: CorePlanItemArg) -> Self { + Self { + step: value.step, + status: value.status.into(), + } + } +} + +impl From for TurnPlanStepStatus { + fn from(value: CorePlanStepStatus) -> Self { + match value { + CorePlanStepStatus::Pending => Self::Pending, + CorePlanStepStatus::InProgress => Self::InProgress, + CorePlanStepStatus::Completed => Self::Completed, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs b/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs new file mode 100644 index 0000000000..3e090c7bfd --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs @@ -0,0 +1,63 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsWorldWritableWarningNotification { + pub sample_paths: Vec, + pub extra_count: usize, + pub failed_scan: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxSetupMode { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxReadiness { + Ready, + NotConfigured, + UpdateRequired, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartParams { + pub mode: WindowsSandboxSetupMode, + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartResponse { + pub started: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxReadinessResponse { + pub status: WindowsSandboxReadiness, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupCompletedNotification { + pub mode: WindowsSandboxSetupMode, + pub success: bool, + pub error: Option, +} diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index b7ff5b1695..6765141bdc 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -14,5 +14,8 @@ codex_rust_crate( "app-server-all-test": 16, "app-server-unit-tests": 8, }, + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], test_tags = ["no-sandbox"], ) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3b80234ad0..ddc3817952 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -303,11 +303,11 @@ Example: { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } -{ "id": 12, "result": { "thread": { "id": "thr_456", … } } } +{ "id": 12, "result": { "thread": { "id": "thr_456", "sessionId": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` @@ -427,6 +427,8 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl Use `thread/turns/list` with `capabilities.experimentalApi = true` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page. +Every returned `Turn` includes `itemsView`, which tells clients whether the `items` array was omitted intentionally (`notLoaded`), contains only summary items (`summary`), or contains every item available from persisted app-server history (`full`). Current `thread/turns/list` responses return `full` turns. + ```json { "method": "thread/turns/list", "id": 24, "params": { "threadId": "thr_123", @@ -1391,6 +1393,12 @@ If the session approval policy uses `Granular` with `request_permissions: false` `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. +Dynamic tool identifiers follow the same constraints as Responses function tools: + +- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters. +- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters. +- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`. + Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: @@ -1539,7 +1547,11 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Each entry is evaluated using that `cwd`'s effective config, so feature gating and discovered config layers can differ across entries in the same request. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Hook state is stored under `hooks.state`; clients should treat hooks from managed sources as non-configurable, and user config entries for those keys are ignored during loading. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. +Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response. + +Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading. + +For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. ```json { @@ -1570,7 +1582,9 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Each entr "source": "user", "pluginId": null, "displayOrder": 0, - "enabled": true + "enabled": true, + "currentHash": "sha256:...", + "trustStatus": "untrusted" }], "warnings": [], "errors": [] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 89b5d26579..d9ff9d1551 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2,17 +2,14 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; -use crate::request_processors::build_api_turns_from_rollout_items; -use crate::request_processors::read_rollout_items_from_rollout; -use crate::request_processors::read_summary_from_rollout; -use crate::request_processors::summary_to_thread; +use crate::request_processors::populate_thread_turns_from_history; +use crate::request_processors::thread_from_stored_thread; use crate::server_request_error::is_turn_transition_server_request_error; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; use crate::thread_state::resolve_server_request_on_thread_listener; use crate::thread_status::ThreadWatchActiveGuard; use crate::thread_status::ThreadWatchManager; -use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo; @@ -55,7 +52,6 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRealtimeClosedNotification; use codex_app_server_protocol::ThreadRealtimeErrorNotification; use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; @@ -65,6 +61,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; use codex_app_server_protocol::ToolRequestUserInputOption; @@ -76,6 +73,7 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnDiffUpdatedNotification; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; @@ -86,7 +84,6 @@ use codex_app_server_protocol::guardian_auto_approval_review_notification; use codex_app_server_protocol::item_event_to_server_notification; use codex_core::CodexThread; use codex_core::ThreadManager; -use codex_core::find_thread_name_by_id; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; @@ -114,14 +111,12 @@ use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; -use std::path::Path; use std::sync::Arc; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; -use tracing::warn; enum CommandExecutionApprovalPresentation { Network(V2NetworkApprovalContext), @@ -141,13 +136,11 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, - analytics_events_client: Option, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, fallback_model_provider: String, - codex_home: &Path, ) { let Event { id: event_turn_id, @@ -162,24 +155,24 @@ pub(crate) async fn apply_bespoke_event_handling( .await; let turn = { let state = thread_state.lock().await; - state.active_turn_snapshot().unwrap_or_else(|| Turn { + let mut turn = state.active_turn_snapshot().unwrap_or_else(|| Turn { id: payload.turn_id.clone(), items: Vec::new(), + items_view: TurnItemsView::NotLoaded, error: None, status: TurnStatus::InProgress, 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(), turn, }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::TurnStarted(notification.clone())); - } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -196,7 +189,6 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, - analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -238,10 +230,6 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: Some(conversation_id.to_string()), message: warning_event.message, }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::Warning(notification.clone())); - } outgoing .send_server_notification(ServerNotification::Warning(notification)) .await; @@ -251,10 +239,6 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), message: warning_event.message, }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::GuardianWarning(notification.clone())); - } outgoing .send_server_notification(ServerNotification::GuardianWarning(notification)) .await; @@ -1139,7 +1123,6 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, - analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1166,84 +1149,48 @@ pub(crate) async fn apply_bespoke_event_handling( return; } }; - let Some(rollout_path) = conversation.rollout_path() else { - outgoing - .send_error( - request_id, - invalid_request("thread has no persisted rollout"), - ) - .await; - return; - }; - let response = match read_summary_from_rollout( - rollout_path.as_path(), - fallback_model_provider.as_str(), - ) - .await + let fallback_cwd = conversation.config_snapshot().await.cwd; + let stored_thread = match conversation + .read_thread( + /*include_archived*/ true, /*include_history*/ true, + ) + .await { - Ok(summary) => { - let fallback_cwd = conversation.config_snapshot().await.cwd; - let mut thread = summary_to_thread(summary, &fallback_cwd); - match read_rollout_items_from_rollout(rollout_path.as_path()).await { - Ok(items) => { - thread.turns = build_api_turns_from_rollout_items(&items); - thread.status = thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; - match find_thread_name_by_id(codex_home, &conversation_id).await { - Ok(name) => { - thread.name = name; - } - Err(err) => { - warn!( - "Failed to read thread name for {conversation_id}: {err}" - ); - } - } - ThreadRollbackResponse { thread } - } - Err(err) => { - outgoing - .send_error( - request_id.clone(), - internal_error(format!( - "failed to load rollout `{}`: {err}", - rollout_path.display() - )), - ) - .await; - return; - } - } - } + Ok(stored_thread) => stored_thread, Err(err) => { outgoing .send_error( request_id.clone(), internal_error(format!( - "failed to load rollout `{}`: {err}", - rollout_path.display() + "failed to read thread {conversation_id} after rollback: {err}" )), ) .await; return; } }; + let loaded_status = thread_watch_manager + .loaded_status_for_thread(&conversation_id.to_string()) + .await; + let response = match thread_rollback_response_from_stored_thread( + stored_thread, + conversation.session_configured().session_id.to_string(), + fallback_model_provider.as_str(), + &fallback_cwd, + loaded_status, + ) { + Ok(response) => response, + Err(err) => { + outgoing + .send_error(request_id.clone(), internal_error(err)) + .await; + return; + } + }; outgoing.send_response(request_id, response).await; } } - EventMsg::ThreadNameUpdated(thread_name_event) => { - let notification = ThreadNameUpdatedNotification { - thread_id: thread_name_event.thread_id.to_string(), - thread_name: thread_name_event.thread_name, - }; - outgoing - .send_global_server_notification(ServerNotification::ThreadNameUpdated( - notification, - )) - .await; - } EventMsg::ThreadGoalUpdated(thread_goal_event) => { let notification = ThreadGoalUpdatedNotification { thread_id: thread_goal_event.thread_id.to_string(), @@ -1328,7 +1275,6 @@ async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, turn_completion_metadata: TurnCompletionMetadata, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1336,6 +1282,7 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], + items_view: TurnItemsView::NotLoaded, error: turn_completion_metadata.error, status: turn_completion_metadata.status, started_at: turn_completion_metadata.started_at, @@ -1343,10 +1290,6 @@ async fn emit_turn_completed_with_status( duration_ms: turn_completion_metadata.duration_ms, }, }; - if let Some(analytics_events_client) = analytics_events_client { - analytics_events_client - .track_notification(ServerNotification::TurnCompleted(notification.clone())); - } outgoing .send_server_notification(ServerNotification::TurnCompleted(notification)) .await; @@ -1510,7 +1453,6 @@ async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, turn_complete_event: TurnCompleteEvent, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -1531,7 +1473,6 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, - analytics_events_client, outgoing, ) .await; @@ -1541,7 +1482,6 @@ async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, turn_aborted_event: TurnAbortedEvent, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -1557,7 +1497,6 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, - analytics_events_client, outgoing, ) .await; @@ -1578,6 +1517,27 @@ async fn handle_thread_rollback_failed( } } +fn thread_rollback_response_from_stored_thread( + stored_thread: codex_thread_store::StoredThread, + session_id: String, + fallback_model_provider: &str, + fallback_cwd: &AbsolutePathBuf, + loaded_status: ThreadStatus, +) -> std::result::Result { + let thread_id = stored_thread.thread_id; + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd); + thread.session_id = session_id; + let Some(history) = history else { + return Err(format!( + "thread {thread_id} did not include persisted history after rollback" + )); + }; + populate_thread_turns_from_history(&mut thread, &history.items, /*active_turn*/ None); + thread.status = loaded_status; + Ok(ThreadRollbackResponse { thread }) +} + async fn respond_to_pending_interrupts( thread_state: &Arc>, outgoing: &ThreadScopedOutgoingMessageSender, @@ -2105,11 +2065,11 @@ mod tests { use anyhow::Result; use anyhow::anyhow; use anyhow::bail; + use chrono::Utc; use codex_app_server_protocol::AutoReviewDecisionSource; use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; - use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; @@ -2121,20 +2081,28 @@ mod tests { use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CreditsSnapshot; + use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; + use codex_protocol::protocol::UserMessageEvent; + use codex_thread_store::StoredThread; + use codex_thread_store::StoredThreadHistory; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; use serde_json::json; - use std::path::PathBuf; use tempfile::TempDir; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -2159,6 +2127,73 @@ mod tests { } } + #[test] + fn rollback_response_rebuilds_pathless_thread_from_stored_history() -> Result<()> { + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000789")?; + let created_at = Utc::now(); + let history_items = vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "before rollback".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "after rollback".to_string(), + phase: None, + memory_citation: None, + })), + ]; + let stored_thread = StoredThread { + thread_id, + rollout_path: None, + forked_from_id: None, + preview: "fallback preview".to_string(), + name: Some("Rollback thread".to_string()), + model_provider: "openai".to_string(), + model: None, + reasoning_effort: None, + created_at, + updated_at: created_at, + archived_at: None, + cwd: test_path_buf("/tmp").abs().into(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + thread_source: None, + agent_nickname: None, + agent_role: None, + agent_path: None, + git_info: None, + approval_mode: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + token_usage: None, + first_user_message: Some("before rollback".to_string()), + history: Some(StoredThreadHistory { + thread_id, + items: history_items, + }), + }; + let fallback_cwd = test_path_buf("/tmp").abs(); + + let response = thread_rollback_response_from_stored_thread( + stored_thread, + thread_id.to_string(), + "fallback-provider", + &fallback_cwd, + ThreadStatus::NotLoaded, + ) + .expect("rollback response should rebuild from stored history"); + + assert_eq!(response.thread.id, thread_id.to_string()); + assert_eq!(response.thread.path, None); + assert_eq!(response.thread.preview, "before rollback"); + assert_eq!(response.thread.name.as_deref(), Some("Rollback thread")); + assert_eq!(response.thread.status, ThreadStatus::NotLoaded); + assert_eq!(response.thread.turns.len(), 1); + assert_eq!(response.thread.turns[0].items.len(), 2); + Ok(()) + } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { TurnCompleteEvent { turn_id: turn_id.to_string(), @@ -2240,8 +2275,6 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, - analytics_events_client: AnalyticsEventsClient, - codex_home: PathBuf, } impl GuardianAssessmentTestContext { @@ -2255,13 +2288,11 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), - Some(self.analytics_events_client.clone()), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), "test-provider".to_string(), - &self.codex_home, ) .await; } @@ -2563,7 +2594,8 @@ mod tests { config.model_provider.clone(), config.codex_home.to_path_buf(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ), + ) + .await, ); let codex_core::NewThread { thread_id: conversation_id, @@ -2589,14 +2621,6 @@ mod tests { outgoing: outgoing.clone(), thread_state: thread_state.clone(), thread_watch_manager: thread_watch_manager.clone(), - analytics_events_client: AnalyticsEventsClient::new( - AuthManager::from_auth_for_testing( - CodexAuth::create_dummy_chatgpt_auth_for_testing(), - ), - "http://localhost".to_string(), - Some(false), - ), - codex_home: codex_home.path().to_path_buf(), }; guardian_context @@ -3139,6 +3163,91 @@ mod tests { Ok(()) } + #[tokio::test] + async fn turn_started_omits_active_snapshot_items() -> Result<()> { + let codex_home = TempDir::new()?; + let config = load_default_config_for_test(&codex_home).await; + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ) + .await, + ); + let codex_core::NewThread { + thread_id: conversation_id, + thread: conversation, + .. + } = thread_manager.start_thread(config.clone()).await?; + let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event( + "turn-1", + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + ); + state.track_current_turn_event( + "turn-1", + &EventMsg::UserMessage(codex_protocol::protocol::UserMessageEvent { + message: "already tracked".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }), + ); + } + let thread_watch_manager = ThreadWatchManager::new(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + + apply_bespoke_event_handling( + Event { + id: "turn-1".to_string(), + msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }, + conversation_id, + conversation, + thread_manager, + outgoing, + thread_state, + thread_watch_manager, + Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), + "test-provider".to_string(), + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnStarted(n)) => { + assert_eq!(n.turn.id, "turn-1"); + assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded); + assert!(n.turn.items.is_empty()); + } + other => bail!("unexpected message: {other:?}"), + } + Ok(()) + } + #[tokio::test] async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> { let conversation_id = ThreadId::new(); @@ -3175,7 +3284,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3186,6 +3294,8 @@ mod tests { OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); + assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded); + assert!(n.turn.items.is_empty()); assert_eq!(n.turn.error, None); assert_eq!(n.turn.started_at, Some(42)); assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); @@ -3227,7 +3337,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3278,7 +3387,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3513,7 +3621,6 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3535,7 +3642,6 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3547,7 +3653,6 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index ba11205b7a..030829fa4b 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -140,6 +140,21 @@ impl ConfigManager { .await } + pub(crate) async fn load_latest_config_for_thread( + &self, + thread_config: &Config, + ) -> std::io::Result { + let refreshed_config = self + .load_latest_config(Some(thread_config.cwd.to_path_buf())) + .await?; + let mut config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) + } + pub(crate) async fn load_default_config(&self) -> std::io::Result { let mut config = Config::load_default_with_cli_overrides_for_codex_home( self.codex_home.clone(), diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 09d57a60a3..35b92548b8 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -52,9 +52,9 @@ use std::time::Duration; use crate::analytics_utils::analytics_events_client_from_config; use crate::config_manager::ConfigManager; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use crate::message_processor::ConnectionSessionState; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; @@ -82,11 +82,13 @@ use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; +use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; use codex_protocol::protocol::SessionSource; -pub use codex_rollout::StateDbHandle; +use codex_rollout::state_db::StateDbHandle; pub use codex_state::log_db::LogDbLayer; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -127,7 +129,7 @@ pub struct InProcessStartArgs { pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. pub log_db: Option, - /// Process-wide SQLite state handle shared with embedded app-server consumers. + /// Optional state DB handle to use for the in-process runtime. pub state_db: Option, /// Environment manager used by core execution and filesystem operations. pub environment_manager: Arc, @@ -344,7 +346,7 @@ impl InProcessClientHandle { /// the runtime is shut down and an `InvalidData` error is returned. pub async fn start(args: InProcessStartArgs) -> IoResult { let initialize = args.initialize.clone(); - let client = start_uninitialized(args); + let client = start_uninitialized(args).await?; let initialize_response = client .request(ClientRequest::Initialize { @@ -364,8 +366,13 @@ pub async fn start(args: InProcessStartArgs) -> IoResult Ok(client) } -fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { +async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); + let state_db = match args.state_db.clone() { + Some(state_db) => Some(state_db), + None => init_state_db_from_config(args.config.as_ref()).await, + }; + let installation_id = resolve_installation_id(&args.config.codex_home).await?; let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -414,6 +421,12 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { ); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { + let Some(state_db) = state_db else { + warn!( + "in-process app-server state db initialization failed; shutting down processor task" + ); + return; + }; let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), analytics_events_client, @@ -423,10 +436,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { environment_manager: args.environment_manager, feedback: args.feedback, log_db: args.log_db, - state_db: args.state_db, + state_db, config_warnings: args.config_warnings, session_source: args.session_source, auth_manager, + installation_id, rpc_transport: AppServerRpcTransport::InProcess, remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, @@ -526,11 +540,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { entry.insert(response_tx); } Entry::Occupied(_) => { - let _ = response_tx.send(Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("duplicate request id: {request_id:?}"), - data: None, - })); + let _ = response_tx.send(Err(invalid_request(format!( + "duplicate request id: {request_id:?}" + )))); continue; } } @@ -553,13 +565,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { if let Some(response_tx) = pending_request_responses.remove(&request_id) { - let _ = response_tx.send(Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: - "in-process app-server request processor is closed" - .to_string(), - data: None, - })); + let _ = response_tx.send(Err(internal_error( + "in-process app-server request processor is closed", + ))); } break; } @@ -627,15 +635,20 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { if let Err(send_error) = event_tx .try_send(InProcessServerEvent::ServerRequest(request)) { - let (code, message, inner) = match send_error { + let (error, inner) = match send_error { mpsc::error::TrySendError::Full(inner) => ( - OVERLOADED_ERROR_CODE, - "in-process server request queue is full", + JSONRPCErrorError { + code: OVERLOADED_ERROR_CODE, + message: + "in-process server request queue is full".to_string(), + data: None, + }, inner, ), mpsc::error::TrySendError::Closed(inner) => ( - INTERNAL_ERROR_CODE, - "in-process server request consumer is closed", + internal_error( + "in-process server request consumer is closed", + ), inner, ), }; @@ -644,14 +657,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { _ => unreachable!("we just sent a ServerRequest variant"), }; outgoing_message_sender - .notify_client_error( - request_id, - JSONRPCErrorError { - code, - message: message.to_string(), - data: None, - }, - ) + .notify_client_error(request_id, error) .await; } } @@ -688,21 +694,17 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { drop(writer_rx); drop(processor_tx); outgoing_message_sender - .cancel_all_requests(Some(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "in-process app-server runtime is shutting down".to_string(), - data: None, - })) + .cancel_all_requests(Some(internal_error( + "in-process app-server runtime is shutting down", + ))) .await; // Drop the runtime's last sender before awaiting the router task so // `outgoing_rx.recv()` can observe channel closure and exit cleanly. drop(outgoing_message_sender); for (_, response_tx) in pending_request_responses { - let _ = response_tx.send(Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "in-process app-server runtime is shutting down".to_string(), - data: None, - })); + let _ = response_tx.send(Err(internal_error( + "in-process app-server runtime is shutting down", + ))); } if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut processor_handle).await { @@ -719,18 +721,19 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } }); - InProcessClientHandle { + Ok(InProcessClientHandle { client: InProcessClientSender { client_tx }, event_rx, runtime_handle, #[cfg(test)] _test_codex_home: None, - } + }) } #[cfg(test)] mod tests { use super::*; + use crate::error_code::INVALID_REQUEST_ERROR_CODE; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::DeviceKeyPublicParams; @@ -743,6 +746,7 @@ mod tests { use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; use codex_core::config::ConfigBuilder; use pretty_assertions::assert_eq; @@ -771,7 +775,7 @@ mod tests { ) -> InProcessClientHandle { let codex_home = TempDir::new().expect("temp dir"); let config = Arc::new(build_test_config(codex_home.path()).await); - let state_db = codex_rollout::state_db::try_init(config.as_ref()) + let state_db = init_state_db_from_config(config.as_ref()) .await .expect("state db should initialize for in-process test"); let args = InProcessStartArgs { @@ -829,7 +833,7 @@ mod tests { } #[tokio::test] - async fn in_process_allows_device_key_requests_to_reach_device_key_processor() { + async fn in_process_allows_device_key_requests_to_reach_device_key_api() { let client = start_test_client(SessionSource::Cli).await; const MALFORMED_KEY_ID_MESSAGE: &str = concat!( "invalid device key payload: keyId must be dk_hse_, dk_tpm_, or dk_osn_ ", @@ -972,6 +976,7 @@ mod tests { turn: Turn { id: "turn-1".to_string(), items: Vec::new(), + items_view: TurnItemsView::NotLoaded, status: TurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 4b1723d88c..c7efae69c6 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -7,6 +7,7 @@ use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; @@ -50,11 +51,11 @@ use codex_config::TextRange as CoreTextRange; use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; use codex_core::config::find_codex_home; +use codex_core::init_state_db_from_config; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; -use codex_rollout::state_db as rollout_state_db; use codex_state::log_db; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -85,6 +86,7 @@ mod filters; mod fs_watch; mod fuzzy_file_search; pub mod in_process; +mod mcp_refresh; mod message_processor; mod models; mod outgoing_message; @@ -487,9 +489,9 @@ pub async fn run_main_with_transport_options( } }; - let state_db_result = rollout_state_db::try_init(&config).await; - let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string); - let state_db = state_db_result.ok(); + let state_db = init_state_db_from_config(&config) + .await + .ok_or_else(|| std::io::Error::other("failed to initialize sqlite state db"))?; if should_run_personality_migration { let effective_toml = config.config_layer_stack.effective_config(); @@ -598,10 +600,12 @@ pub async fn run_main_with_transport_options( let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); - let log_db = state_db.clone().map(log_db::start); - let log_db_layer = log_db - .clone() - .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + let log_db = log_db::start(state_db.clone()); + let log_db_layer = Some( + log_db + .clone() + .with_filter(Targets::new().with_default(Level::TRACE)), + ); let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); let _ = tracing_subscriber::registry() @@ -618,10 +622,7 @@ pub async fn run_main_with_transport_options( None => error!("{}", warning.summary), } } - if let Some(err) = &state_db_init_error { - error!("failed to initialize sqlite state db: {err}"); - } - + let installation_id = resolve_installation_id(&config.codex_home).await?; let transport_shutdown_token = CancellationToken::new(); let mut transport_accept_handles = Vec::>::new(); @@ -666,25 +667,17 @@ pub async fn run_main_with_transport_options( let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; - let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl); - let remote_control_enabled = remote_control_config_enabled && state_db.is_some(); - if remote_control_config_enabled && state_db.is_none() { - error!("remote control disabled because sqlite state db is unavailable"); - } + let remote_control_enabled = config.features.enabled(Feature::RemoteControl); if transport_accept_handles.is_empty() && !remote_control_enabled { return Err(std::io::Error::new( ErrorKind::InvalidInput, - if remote_control_config_enabled && state_db.is_none() { - "no transport configured; remote control disabled because sqlite state db is unavailable" - } else { - "no transport configured; use --listen or enable remote control" - }, + "no transport configured; use --listen or enable remote control", )); } let (remote_control_accept_handle, remote_control_handle) = start_remote_control( config.chatgpt_base_url.clone(), - state_db.clone(), + Some(state_db.clone()), auth_manager.clone(), transport_event_tx.clone(), transport_shutdown_token.clone(), @@ -768,11 +761,12 @@ pub async fn run_main_with_transport_options( config_manager, environment_manager, feedback: feedback.clone(), - log_db, + log_db: Some(log_db), state_db: state_db.clone(), config_warnings, session_source, auth_manager, + installation_id, rpc_transport: analytics_rpc_transport(&transport), remote_control_handle: Some(remote_control_handle.clone()), plugin_startup_tasks: runtime_options.plugin_startup_tasks, diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs new file mode 100644 index 0000000000..b1471d88fc --- /dev/null +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -0,0 +1,242 @@ +use crate::config_manager::ConfigManager; +use codex_core::CodexThread; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::McpServerRefreshConfig; +use codex_protocol::protocol::Op; +use std::io; +use std::sync::Arc; +use tracing::warn; + +pub(crate) async fn queue_strict_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) -> io::Result<()> { + config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await?; + let mut refreshes = Vec::new(); + for thread_id in thread_manager.list_thread_ids().await { + let thread = thread_manager + .get_thread(thread_id) + .await + .map_err(|err| io::Error::other(format!("failed to load thread {thread_id}: {err}")))?; + let config = + build_refresh_config(thread_manager, config_manager, thread.config().await).await?; + refreshes.push((thread_id, thread, config)); + } + for (thread_id, thread, config) in refreshes { + queue_refresh(thread_id, thread, config).await?; + } + Ok(()) +} + +pub(crate) async fn queue_best_effort_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) { + for thread_id in thread_manager.list_thread_ids().await { + let thread = match thread_manager.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + warn!("failed to load thread {thread_id} for MCP refresh: {err}"); + continue; + } + }; + let config = + match build_refresh_config(thread_manager, config_manager, thread.config().await).await + { + Ok(config) => config, + Err(err) => { + warn!("failed to build MCP refresh config for thread {thread_id}: {err}"); + continue; + } + }; + if let Err(err) = queue_refresh(thread_id, thread, config).await { + warn!("{err}"); + } + } +} + +async fn build_refresh_config( + thread_manager: &ThreadManager, + config_manager: &ConfigManager, + thread_config: Arc, +) -> io::Result { + let config = config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await?; + let mcp_servers = thread_manager + .mcp_manager() + .configured_servers(&config) + .await; + Ok(McpServerRefreshConfig { + mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + ) + .map_err(io::Error::other)?, + }) +} + +async fn queue_refresh( + thread_id: ThreadId, + thread: Arc, + config: McpServerRefreshConfig, +) -> io::Result<()> { + thread + .submit(Op::RefreshMcpServers { config }) + .await + .map(|_| ()) + .map_err(|err| { + io::Error::other(format!( + "failed to queue MCP refresh for thread {thread_id}: {err}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use codex_arg0::Arg0DispatchPaths; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::ThreadConfigContext; + use codex_config::ThreadConfigLoadError; + use codex_config::ThreadConfigLoadErrorCode; + use codex_config::ThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_core::agent_graph_store_from_state_db; + use codex_core::config::ConfigOverrides; + use codex_core::init_state_db_from_config; + use codex_core::thread_store_from_config; + use codex_exec_server::EnvironmentManager; + use codex_login::AuthManager; + use codex_login::CodexAuth; + use codex_protocol::protocol::SessionSource; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + + #[tokio::test] + async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?; + + let err = queue_strict_refresh(&thread_manager, &config_manager) + .await + .expect_err("strict refresh should fail"); + + assert_eq!(err.to_string(), "failed to load refresh config"); + Ok(()) + } + + #[tokio::test] + async fn best_effort_refresh_attempts_every_loaded_thread() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, loader) = refresh_test_state().await?; + + queue_best_effort_refresh(&thread_manager, &config_manager).await; + + assert_eq!(loader.good_loads.load(Ordering::Relaxed), 1); + assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 1); + Ok(()) + } + + async fn refresh_test_state() -> anyhow::Result<( + TempDir, + Arc, + ConfigManager, + Arc, + )> { + let temp_dir = TempDir::new()?; + let good_cwd = temp_dir.path().join("good"); + let bad_cwd = temp_dir.path().join("bad"); + std::fs::create_dir_all(&good_cwd)?; + std::fs::create_dir_all(&bad_cwd)?; + + let initial_config_manager = + ConfigManager::without_managed_config_for_tests(temp_dir.path().to_path_buf()); + let good_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(good_cwd.clone()), + ) + .await?; + let bad_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(bad_cwd.clone()), + ) + .await?; + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")); + let state_db = init_state_db_from_config(&good_config) + .await + .expect("refresh tests require state db"); + let thread_store = thread_store_from_config(&good_config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let thread_manager = Arc::new(ThreadManager::new( + &good_config, + auth_manager, + SessionSource::Exec, + Arc::new(EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + "11111111-1111-4111-8111-111111111111".to_string(), + )); + thread_manager.start_thread(good_config).await?; + thread_manager.start_thread(bad_config).await?; + + let loader = Arc::new(CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf::try_from(good_cwd)?, + bad_cwd: AbsolutePathBuf::try_from(bad_cwd)?, + good_loads: AtomicUsize::new(0), + bad_loads: AtomicUsize::new(0), + }); + let config_manager = ConfigManager::new( + temp_dir.path().to_path_buf(), + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + loader.clone(), + ); + + Ok((temp_dir, thread_manager, config_manager, loader)) + } + + struct CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf, + bad_cwd: AbsolutePathBuf, + good_loads: AtomicUsize, + bad_loads: AtomicUsize, + } + + #[async_trait] + impl ThreadConfigLoader for CountingThreadConfigLoader { + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + if context.cwd.as_ref() == Some(&self.good_cwd) { + self.good_loads.fetch_add(1, Ordering::Relaxed); + } + if context.cwd.as_ref() == Some(&self.bad_cwd) { + self.bad_loads.fetch_add(1, Ordering::Relaxed); + return Err(ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Internal, + /*status_code*/ None, + "failed to load refresh config", + )); + } + Ok(Vec::new()) + } + } +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6054212045..1ac635685f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -61,6 +61,7 @@ use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; use codex_chatgpt::workspace_settings; use codex_core::ThreadManager; +use codex_core::agent_graph_store_from_state_db; use codex_core::config::Config; use codex_core::thread_store_from_config; use codex_exec_server::EnvironmentManager; @@ -254,10 +255,11 @@ pub(crate) struct MessageProcessorArgs { pub(crate) environment_manager: Arc, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, - pub(crate) state_db: Option, + pub(crate) state_db: StateDbHandle, pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, pub(crate) auth_manager: Arc, + pub(crate) installation_id: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) remote_control_handle: Option, pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, @@ -280,6 +282,7 @@ impl MessageProcessor { config_warnings, session_source, auth_manager, + installation_id, rpc_transport, remote_control_handle, plugin_startup_tasks, @@ -291,14 +294,17 @@ impl MessageProcessor { // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. let thread_store = thread_store_from_config(config.as_ref(), state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), session_source, environment_manager, Some(analytics_events_client.clone()), - Arc::clone(&thread_store), state_db.clone(), + Arc::clone(&thread_store), + agent_graph_store.clone(), + installation_id, )); thread_manager .plugins_manager() @@ -344,7 +350,7 @@ impl MessageProcessor { Arc::clone(&config), feedback, log_db, - state_db.clone(), + Some(state_db.clone()), ); let git_processor = GitRequestProcessor::new(); let initialize_processor = InitializeRequestProcessor::new( @@ -385,7 +391,6 @@ impl MessageProcessor { auth_manager.clone(), Arc::clone(&thread_manager), outgoing.clone(), - analytics_events_client.clone(), arg0_paths.clone(), Arc::clone(&config), config_manager.clone(), @@ -395,7 +400,7 @@ impl MessageProcessor { thread_watch_manager.clone(), Arc::clone(&thread_list_state_permit), thread_goal_processor.clone(), - state_db.clone(), + Some(state_db.clone()), ); let turn_processor = TurnRequestProcessor::new( auth_manager.clone(), @@ -409,12 +414,11 @@ impl MessageProcessor { thread_state_manager, thread_watch_manager, thread_list_state_permit, - state_db.clone(), ); if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { // Keep plugin startup warmups aligned at app-server startup. let on_effective_plugins_changed = - plugin_processor.effective_plugins_changed_callback((*config).clone()); + plugin_processor.effective_plugins_changed_callback(); thread_manager .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( @@ -832,6 +836,11 @@ impl MessageProcessor { .read(params) .await .map(|response| Some(response.into())), + ClientRequest::WindowsSandboxReadiness { .. } => self + .windows_sandbox_processor + .windows_sandbox_readiness() + .await + .map(|response| Some(response.into())), ClientRequest::ExternalAgentConfigDetect { params, .. } => self .external_agent_config_processor .detect(params) @@ -950,12 +959,22 @@ impl MessageProcessor { } ClientRequest::ThreadResume { params, .. } => { self.thread_processor - .thread_resume(request_id.clone(), params) + .thread_resume( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) .await } ClientRequest::ThreadFork { params, .. } => { self.thread_processor - .thread_fork(request_id.clone(), params) + .thread_fork( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) .await } ClientRequest::ThreadArchive { params, .. } => { @@ -1070,6 +1089,11 @@ impl MessageProcessor { ClientRequest::PluginShareSave { params, .. } => { self.plugin_processor.plugin_share_save(params).await } + ClientRequest::PluginShareUpdateTargets { params, .. } => { + self.plugin_processor + .plugin_share_update_targets(params) + .await + } ClientRequest::PluginShareList { params, .. } => { self.plugin_processor.plugin_share_list(params).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 45ea709180..27e2c2f473 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -32,6 +32,7 @@ use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; +use codex_core::init_state_db_from_config; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; @@ -281,6 +282,9 @@ async fn build_test_processor( outgoing_tx, analytics_events_client.clone(), )); + let state_db = init_state_db_from_config(config.as_ref()) + .await + .expect("tracing test processor requires state db"); let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing, analytics_events_client, @@ -290,10 +294,11 @@ async fn build_test_processor( environment_manager: Arc::new(EnvironmentManager::default_for_tests()), feedback: CodexFeedback::new(), log_db: None, - state_db: None, + state_db, config_warnings: Vec::new(), session_source: SessionSource::VSCode, auth_manager, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), rpc_transport: AppServerRpcTransport::Stdio, remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index fd08098f74..4d75a20580 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use codex_app_server_protocol::Model; +use codex_app_server_protocol::ModelServiceTier; use codex_app_server_protocol::ModelUpgradeInfo; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ThreadManager; @@ -43,6 +44,15 @@ fn model_from_preset(preset: ModelPreset) -> Model { input_modalities: preset.input_modalities, supports_personality: preset.supports_personality, additional_speed_tiers: preset.additional_speed_tiers, + service_tiers: preset + .service_tiers + .into_iter() + .map(|service_tier| ModelServiceTier { + id: service_tier.id, + name: service_tier.name, + description: service_tier.description, + }) + .collect(), is_default: preset.is_default, } } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index f7a90538c2..a7420f8c78 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -21,7 +21,6 @@ use tracing::Instrument; use tracing::Span; use tracing::warn; -use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::internal_error; use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON; pub(crate) use codex_app_server_transport::ConnectionId; @@ -141,6 +140,9 @@ impl ThreadScopedOutgoingMessageSender { } pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + self.outgoing + .analytics_events_client + .track_notification(notification.clone()); if self.connection_ids.is_empty() { return; } @@ -157,11 +159,14 @@ impl ThreadScopedOutgoingMessageSender { self.outgoing .cancel_requests_for_thread( self.thread_id, - Some(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "client request resolved because the turn state was changed" - .to_string(), - data: Some(serde_json::json!({ "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON })), + Some({ + let mut error = internal_error( + "client request resolved because the turn state was changed", + ); + error.data = Some(serde_json::json!({ + "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON, + })); + error }), ) .await @@ -524,7 +529,7 @@ impl OutgoingMessageSender { targeted_connections = connection_ids.len(), "app-server event: {notification}" ); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); if connection_ids.is_empty() { if let Err(err) = self .sender @@ -558,7 +563,7 @@ impl OutgoingMessageSender { notification: ServerNotification, ) { tracing::trace!("app-server event: {notification}"); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); let (write_complete_tx, write_complete_rx) = oneshot::channel(); if let Err(err) = self .sender @@ -1011,11 +1016,7 @@ mod tests { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), }; - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "boom".to_string(), - data: None, - }; + let error = internal_error("boom"); outgoing.send_error(request_id.clone(), error.clone()).await; @@ -1139,11 +1140,7 @@ mod tests { )) .await; - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "refresh failed".to_string(), - data: None, - }; + let error = internal_error("refresh failed"); outgoing .notify_client_error(request_id, error.clone()) @@ -1253,11 +1250,7 @@ mod tests { }, )) .await; - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "tracked request cancelled".to_string(), - data: None, - }; + let error = internal_error("tracked request cancelled"); outgoing .cancel_requests_for_thread(thread_id, Some(error.clone())) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 55b3aeef13..9f419d7006 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -4,9 +4,6 @@ use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; use crate::config_manager::ConfigManager; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_PARAMS_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::invalid_params; use crate::models::supported_models; use crate::outgoing_message::ConnectionId; @@ -52,6 +49,7 @@ use codex_app_server_protocol::CommandExecWriteParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; +use codex_app_server_protocol::DeprecationNoticeNotification; use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; use codex_app_server_protocol::ExperimentalFeatureListParams; @@ -117,11 +115,17 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginShareDeleteParams; use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListParams; use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveParams; use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareTarget; +use codex_app_server_protocol::PluginShareUpdateTargetsParams; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; use codex_app_server_protocol::PluginSkillReadParams; use codex_app_server_protocol::PluginSkillReadResponse; use codex_app_server_protocol::PluginSource; @@ -224,12 +228,15 @@ use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_app_server_protocol::WindowsSandboxReadiness; +use codex_app_server_protocol::WindowsSandboxReadinessResponse; use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; @@ -246,9 +253,11 @@ use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::CodexThreadTurnContextOverrides; +use codex_core::ExternalGoalPreviousStatus; +use codex_core::ExternalGoalSet; use codex_core::ForkSnapshot; use codex_core::NewThread; -use codex_core::RolloutRecorder; +#[cfg(test)] use codex_core::SessionMeta; use codex_core::StartThreadOptions; use codex_core::SteerInputError; @@ -263,15 +272,16 @@ use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecExpiration; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; -use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_name_by_id; use codex_core::find_thread_path_by_id_str; use codex_core::path_utils; +#[cfg(test)] use codex_core::read_head_for_summary; use codex_core::sandboxing::SandboxPermissions; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; use codex_core::windows_sandbox::WindowsSandboxSetupRequest; +use codex_core::windows_sandbox::sandbox_setup_is_complete; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallError as CorePluginInstallError; use codex_core_plugins::PluginInstallRequest; @@ -337,6 +347,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +#[cfg(test)] use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::permissions::FileSystemSandboxPolicy; @@ -346,10 +357,10 @@ use codex_protocol::protocol::ConversationStartParams; use codex_protocol::protocol::ConversationStartTransport; use codex_protocol::protocol::ConversationTextParams; use codex_protocol::protocol::EventMsg; +#[cfg(test)] use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; -use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RealtimeVoicesList; @@ -359,6 +370,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; +#[cfg(test)] use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::USER_MESSAGE_BEGIN; @@ -370,11 +382,10 @@ use codex_rollout::EventPersistenceMode; use codex_rollout::is_persisted_rollout_item; use codex_rollout::state_db::StateDbHandle; use codex_rollout::state_db::reconcile_rollout; -use codex_state::StateRuntime; use codex_state::ThreadMetadata; -use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; +use codex_thread_store::GitInfoPatch as StoreGitInfoPatch; use codex_thread_store::ListThreadsParams as StoreListThreadsParams; use codex_thread_store::LocalThreadStore; use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams; @@ -479,7 +490,9 @@ use self::thread_goal_processor::api_thread_goal_from_state; use self::thread_lifecycle::*; use self::thread_summary::*; -pub(crate) use self::thread_summary::read_rollout_items_from_rollout; +pub(crate) use self::thread_lifecycle::populate_thread_turns_from_history; +pub(crate) use self::thread_processor::thread_from_stored_thread; +#[cfg(test)] pub(crate) use self::thread_summary::read_summary_from_rollout; pub(crate) use self::thread_summary::summary_to_thread; 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 b26d1e3e1f..c73d6700e7 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -168,7 +168,7 @@ impl AccountRequestProcessor { { Ok(config) => { let refresh_thread_manager = Arc::clone(thread_manager); - let refresh_config = config.clone(); + let refresh_config_manager = config_manager.clone(); thread_manager .plugins_manager() .maybe_start_remote_installed_plugins_cache_refresh( @@ -177,7 +177,7 @@ impl AccountRequestProcessor { Some(Arc::new(move || { Self::spawn_effective_plugins_changed_task( Arc::clone(&refresh_thread_manager), - refresh_config.clone(), + refresh_config_manager.clone(), ); })), ); @@ -190,19 +190,17 @@ impl AccountRequestProcessor { } } - fn spawn_effective_plugins_changed_task(thread_manager: Arc, config: Config) { + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); thread_manager.skills_manager().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } - if let Err(err) = - McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config) - .await - { - warn!("failed to queue MCP refresh after effective plugins changed: {err:?}"); - } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; }); } @@ -243,12 +241,9 @@ impl AccountRequestProcessor { } fn external_auth_active_error(&self) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it." - .to_string(), - data: None, - } + invalid_request( + "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it.", + ) } async fn login_api_key_common( @@ -263,11 +258,9 @@ impl AccountRequestProcessor { self.config.forced_login_method, Some(ForcedLoginMethod::Chatgpt) ) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "API key login is disabled. Use ChatGPT login instead.".to_string(), - data: None, - }); + return Err(invalid_request( + "API key login is disabled. Use ChatGPT login instead.", + )); } // Cancel any active login attempt. @@ -287,11 +280,7 @@ impl AccountRequestProcessor { self.auth_manager.reload().await; Ok(()) } - Err(err) => Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to save api key: {err}"), - data: None, - }), + Err(err) => Err(internal_error(format!("failed to save api key: {err}"))), } } @@ -321,11 +310,9 @@ impl AccountRequestProcessor { } if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "ChatGPT login is disabled. Use API key login instead.".to_string(), - data: None, - }); + return Err(invalid_request( + "ChatGPT login is disabled. Use API key login instead.", + )); } let opts = LoginServerOptions { @@ -354,18 +341,10 @@ impl AccountRequestProcessor { fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { let is_not_found = err.kind() == std::io::ErrorKind::NotFound; - JSONRPCErrorError { - code: if is_not_found { - INVALID_REQUEST_ERROR_CODE - } else { - INTERNAL_ERROR_CODE - }, - message: if is_not_found { - err.to_string() - } else { - format!("failed to request device code: {err}") - }, - data: None, + if is_not_found { + invalid_request(err.to_string()) + } else { + internal_error(format!("failed to request device code: {err}")) } } @@ -698,11 +677,7 @@ impl AccountRequestProcessor { match self.auth_manager.logout_with_revoke().await { Ok(_) => {} Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("logout failed: {err}"), - data: None, - }); + return Err(internal_error(format!("logout failed: {err}"))); } } @@ -885,28 +860,19 @@ impl AccountRequestProcessor { params: SendAddCreditsNudgeEmailParams, ) -> Result { let Some(auth) = self.auth_manager.auth().await else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "codex account authentication required to notify workspace owner" - .to_string(), - data: None, - }); + return Err(invalid_request( + "codex account authentication required to notify workspace owner", + )); }; if !auth.uses_codex_backend() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "chatgpt authentication required to notify workspace owner".to_string(), - data: None, - }); + return Err(invalid_request( + "chatgpt authentication required to notify workspace owner", + )); } let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to construct backend client: {err}"), - data: None, - })?; + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; match client .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) @@ -916,11 +882,9 @@ impl AccountRequestProcessor { Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { Ok(AddCreditsNudgeEmailStatus::CooldownActive) } - Err(err) => Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to notify workspace owner: {err}"), - data: None, - }), + Err(err) => Err(internal_error(format!( + "failed to notify workspace owner: {err}" + ))), } } @@ -941,42 +905,28 @@ impl AccountRequestProcessor { JSONRPCErrorError, > { let Some(auth) = self.auth_manager.auth().await else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "codex account authentication required to read rate limits".to_string(), - data: None, - }); + return Err(invalid_request( + "codex account authentication required to read rate limits", + )); }; if !auth.uses_codex_backend() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "chatgpt authentication required to read rate limits".to_string(), - data: None, - }); + return Err(invalid_request( + "chatgpt authentication required to read rate limits", + )); } let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to construct backend client: {err}"), - data: None, - })?; + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; let snapshots = client .get_rate_limits_many() .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to fetch codex rate limits: {err}"), - data: None, - })?; + .map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?; if snapshots.is_empty() { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "failed to fetch codex rate limits: no snapshots returned".to_string(), - data: None, - }); + return Err(internal_error( + "failed to fetch codex rate limits: no snapshots returned", + )); } let rate_limits_by_limit_id: HashMap = snapshots diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 917641dd3e..da2956dbab 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -231,21 +231,14 @@ impl AppsRequestProcessor { &self, thread_id: &str, ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { - let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - })?; + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let thread = self .thread_manager .get_thread(thread_id) .await - .map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {thread_id}"), - data: None, - })?; + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; Ok((thread_id, thread)) } @@ -257,11 +250,7 @@ impl AppsRequestProcessor { self.config_manager .load_latest_config(fallback_cwd) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to reload config: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } async fn workspace_codex_plugins_enabled( @@ -319,11 +308,9 @@ fn paginate_apps( ) -> Result { let total = connectors.len(); if start > total { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("cursor {start} exceeds total apps {total}"), - data: None, - }); + return Err(invalid_request(format!( + "cursor {start} exceeds total apps {total}" + ))); } let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index c2876514a5..f93f9cc08c 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -72,6 +72,8 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { display_order: hook.display_order, enabled: hook.enabled, is_managed: hook.is_managed, + current_hash: hook.current_hash.clone(), + trust_status: hook.trust_status.into(), }) .collect() } @@ -190,11 +192,7 @@ impl CatalogRequestProcessor { self.config_manager .load_latest_config(fallback_cwd) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to reload config: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } async fn workspace_codex_plugins_enabled( diff --git a/codex-rs/app-server/src/request_processors/config_errors.rs b/codex-rs/app-server/src/request_processors/config_errors.rs index ea1664031b..63fe2b3d2c 100644 --- a/codex-rs/app-server/src/request_processors/config_errors.rs +++ b/codex-rs/app-server/src/request_processors/config_errors.rs @@ -29,9 +29,7 @@ pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { data }); - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("failed to load configuration: {err}"), - data, - } + let mut error = invalid_request(format!("failed to load configuration: {err}")); + error.data = data; + error } 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 8043675e0d..f084867719 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use crate::config_manager::ConfigManager; use crate::config_manager_service::ConfigManagerError; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; @@ -612,11 +611,9 @@ fn map_error(err: ConfigManagerError) -> JSONRPCErrorError { } fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.into(), - data: Some(json!({ - "config_write_error_code": code, - })), - } + let mut error = invalid_request(message); + error.data = Some(json!({ + "config_write_error_code": code, + })); + error } diff --git a/codex-rs/app-server/src/request_processors/device_key_processor.rs b/codex-rs/app-server/src/request_processors/device_key_processor.rs index ea0a96c2af..e1da459a2e 100644 --- a/codex-rs/app-server/src/request_processors/device_key_processor.rs +++ b/codex-rs/app-server/src/request_processors/device_key_processor.rs @@ -33,8 +33,8 @@ use codex_device_key::RemoteControlClientConnectionAudience; use codex_device_key::RemoteControlClientConnectionSignPayload; use codex_device_key::RemoteControlClientEnrollmentAudience; use codex_device_key::RemoteControlClientEnrollmentSignPayload; +use codex_rollout::state_db::StateDbHandle; use codex_state::DeviceKeyBindingRecord; -use codex_state::StateRuntime; #[derive(Clone)] pub(crate) struct DeviceKeyRequestProcessor { @@ -43,10 +43,7 @@ pub(crate) struct DeviceKeyRequestProcessor { } impl DeviceKeyRequestProcessor { - pub(crate) fn new( - outgoing: Arc, - state_db: Option>, - ) -> Self { + pub(crate) fn new(outgoing: Arc, state_db: StateDbHandle) -> Self { Self { outgoing, store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(state_db))), @@ -170,25 +167,18 @@ async fn sign_device_key( } struct StateDeviceKeyBindingStore { - state_db: Option>, + state_db: StateDbHandle, } impl StateDeviceKeyBindingStore { - fn new(state_db: Option>) -> Self { + fn new(state_db: StateDbHandle) -> Self { Self { state_db } } - - async fn state_db(&self) -> Result, DeviceKeyError> { - self.state_db - .clone() - .ok_or_else(|| DeviceKeyError::Platform("sqlite state db unavailable".to_string())) - } } impl fmt::Debug for StateDeviceKeyBindingStore { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("StateDeviceKeyBindingStore") - .field("has_state_db", &self.state_db.is_some()) .finish_non_exhaustive() } } @@ -196,7 +186,7 @@ impl fmt::Debug for StateDeviceKeyBindingStore { #[async_trait] impl DeviceKeyBindingStore for StateDeviceKeyBindingStore { async fn get_binding(&self, key_id: &str) -> Result, DeviceKeyError> { - let state_db = self.state_db().await?; + let state_db = self.state_db.clone(); state_db .get_device_key_binding(key_id) .await @@ -214,7 +204,7 @@ impl DeviceKeyBindingStore for StateDeviceKeyBindingStore { key_id: &str, binding: &DeviceKeyBinding, ) -> Result<(), DeviceKeyError> { - let state_db = self.state_db().await?; + let state_db = self.state_db.clone(); state_db .upsert_device_key_binding(&DeviceKeyBindingRecord { key_id: key_id.to_string(), diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 200d8e89df..1c741944b5 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -36,7 +36,7 @@ use codex_external_agent_sessions::prepare_validated_session_imports; use codex_external_agent_sessions::record_imported_session; use codex_protocol::ThreadId; use codex_protocol::protocol::InitialHistory; -use codex_protocol::protocol::Op; +use codex_thread_store::ThreadMetadataPatch; use std::collections::HashSet; use std::path::PathBuf; use tokio::sync::Semaphore; @@ -307,6 +307,7 @@ impl ExternalAgentConfigRequestProcessor { config, initial_history: InitialHistory::Forked(rollout_items), session_source: None, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -320,7 +321,13 @@ impl ExternalAgentConfigRequestProcessor { { imported_thread .thread - .submit(Op::SetThreadName { name }) + .update_thread_metadata( + ThreadMetadataPatch { + name: Some(name), + ..Default::default() + }, + /*include_archived*/ false, + ) .await .map_err(|err| internal_error(format!("failed to name imported session: {err}")))?; } diff --git a/codex-rs/app-server/src/request_processors/marketplace_processor.rs b/codex-rs/app-server/src/request_processors/marketplace_processor.rs index fa5bc3d827..1a09507418 100644 --- a/codex-rs/app-server/src/request_processors/marketplace_processor.rs +++ b/codex-rs/app-server/src/request_processors/marketplace_processor.rs @@ -132,10 +132,6 @@ impl MarketplaceRequestProcessor { self.config_manager .load_latest_config(fallback_cwd) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to reload config: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } } diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index e161fc4fa4..243506f6af 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -77,8 +77,9 @@ impl McpRequestProcessor { &self, _params: Option<()>, ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - Self::queue_mcp_server_refresh_for_config(&self.thread_manager, &config).await?; + crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager) + .await + .map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?; Ok(McpServerRefreshResponse {}) } @@ -89,80 +90,25 @@ impl McpRequestProcessor { self.config_manager .load_latest_config(fallback_cwd) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to reload config: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } async fn load_thread( &self, thread_id: &str, ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { - let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - })?; + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let thread = self .thread_manager .get_thread(thread_id) .await - .map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {thread_id}"), - data: None, - })?; + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; Ok((thread_id, thread)) } - pub(super) async fn queue_mcp_server_refresh_for_config( - thread_manager: &Arc, - config: &Config, - ) -> Result<(), JSONRPCErrorError> { - let configured_servers = thread_manager - .mcp_manager() - .configured_servers(config) - .await; - let mcp_servers = match serde_json::to_value(configured_servers) { - Ok(value) => value, - Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to serialize MCP servers: {err}"), - data: None, - }); - } - }; - - let mcp_oauth_credentials_store_mode = - match serde_json::to_value(config.mcp_oauth_credentials_store_mode) { - Ok(value) => value, - Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to serialize MCP OAuth credentials store mode: {err}" - ), - data: None, - }); - } - }; - - let refresh_config = McpServerRefreshConfig { - mcp_servers, - mcp_oauth_credentials_store_mode, - }; - - // Refresh requests are queued per thread; each thread rebuilds MCP connections on its next - // active turn to avoid work for threads that never resume. - thread_manager.refresh_mcp_servers(refresh_config).await; - Ok(()) - } - async fn mcp_server_oauth_login_response( &self, params: McpServerOauthLoginParams, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 068dc04ca9..7d542edf39 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -86,6 +86,66 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS } } +fn remote_plugin_share_discoverability( + discoverability: PluginShareDiscoverability, +) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { + match discoverability { + PluginShareDiscoverability::Listed => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed + } + PluginShareDiscoverability::Unlisted => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted + } + PluginShareDiscoverability::Private => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Private + } + } +} + +fn remote_plugin_share_targets( + targets: Vec, +) -> Vec { + targets + .into_iter() + .map( + |target| codex_core_plugins::remote::RemotePluginShareTarget { + principal_type: match target.principal_type { + PluginSharePrincipalType::User => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User + } + PluginSharePrincipalType::Group => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group + } + PluginSharePrincipalType::Workspace => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace + } + }, + principal_id: target.principal_id, + }, + ) + .collect() +} + +fn plugin_share_principal_from_remote( + principal: codex_core_plugins::remote::RemotePluginSharePrincipal, +) -> PluginSharePrincipal { + PluginSharePrincipal { + principal_type: match principal.principal_type { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User => { + PluginSharePrincipalType::User + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => { + PluginSharePrincipalType::Group + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => { + PluginSharePrincipalType::Workspace + } + }, + principal_id: principal.principal_id, + name: principal.name, + } +} + impl PluginRequestProcessor { pub(crate) fn new( auth_manager: Arc, @@ -141,6 +201,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_share_update_targets( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_update_targets_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_share_list( &self, params: PluginShareListParams, @@ -177,33 +246,35 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } - pub(crate) fn effective_plugins_changed_callback( - &self, - config: Config, - ) -> Arc { + pub(crate) fn effective_plugins_changed_callback(&self) -> Arc { let thread_manager = Arc::clone(&self.thread_manager); + let config_manager = self.config_manager.clone(); Arc::new(move || { - Self::spawn_effective_plugins_changed_task(Arc::clone(&thread_manager), config.clone()); + Self::spawn_effective_plugins_changed_task( + Arc::clone(&thread_manager), + config_manager.clone(), + ); }) } - fn on_effective_plugins_changed(&self, config: Config) { - Self::spawn_effective_plugins_changed_task(Arc::clone(&self.thread_manager), config); + fn on_effective_plugins_changed(&self) { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&self.thread_manager), + self.config_manager.clone(), + ); } - fn spawn_effective_plugins_changed_task(thread_manager: Arc, config: Config) { + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); thread_manager.skills_manager().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } - if let Err(err) = - McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config) - .await - { - warn!("failed to queue MCP refresh after effective plugins changed: {err:?}"); - } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; }); } @@ -219,11 +290,7 @@ impl PluginRequestProcessor { self.config_manager .load_latest_config(fallback_cwd) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to reload config: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) } async fn workspace_codex_plugins_enabled( @@ -277,7 +344,7 @@ impl PluginRequestProcessor { &plugins_input, auth.clone(), &roots, - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); let config_for_marketplace_listing = plugins_input.clone(); @@ -314,6 +381,7 @@ impl PluginRequestProcessor { auth_policy: plugin.policy.authentication.into(), availability: PluginAvailability::Available, interface: plugin.interface.map(local_plugin_interface_to_info), + keywords: plugin.keywords, }) .collect(), }) @@ -469,6 +537,7 @@ impl PluginRequestProcessor { auth_policy: outcome.plugin.policy.authentication.into(), availability: PluginAvailability::Available, interface: outcome.plugin.interface.map(local_plugin_interface_to_info), + keywords: outcome.plugin.keywords, }, description: outcome.plugin.description, skills: plugin_skills_to_info( @@ -572,22 +641,34 @@ impl PluginRequestProcessor { let PluginShareSaveParams { plugin_path, remote_plugin_id, + discoverability, + share_targets, } = params; if let Some(remote_plugin_id) = remote_plugin_id.as_ref() && (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id)) { return Err(invalid_request("invalid remote plugin id")); } + if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) { + return Err(invalid_request( + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets", + )); + } let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; + let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { + discoverability: discoverability.map(remote_plugin_share_discoverability), + share_targets: share_targets.map(remote_plugin_share_targets), + }; let result = codex_core_plugins::remote::save_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), config.codex_home.as_path(), &plugin_path, remote_plugin_id.as_deref(), + access_policy, ) .await .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; @@ -599,6 +680,42 @@ impl PluginRequestProcessor { }) } + async fn plugin_share_update_targets_response( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareUpdateTargetsParams { + remote_plugin_id, + share_targets, + } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let result = codex_core_plugins::remote::update_remote_plugin_share_targets( + &remote_plugin_service_config, + auth.as_ref(), + &remote_plugin_id, + remote_plugin_share_targets(share_targets), + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets") + })?; + self.clear_plugin_related_caches(); + Ok(PluginShareUpdateTargetsResponse { + principals: result + .principals + .into_iter() + .map(plugin_share_principal_from_remote) + .collect(), + }) + } + async fn plugin_share_list_response( &self, _params: PluginShareListParams, @@ -725,7 +842,7 @@ impl PluginRequestProcessor { } }; - self.on_effective_plugins_changed(config.clone()); + self.on_effective_plugins_changed(); let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; if !plugin_mcp_servers.is_empty() { @@ -836,7 +953,7 @@ impl PluginRequestProcessor { .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( &config.plugins_config_input(), auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); let mut plugin_metadata = @@ -1029,7 +1146,7 @@ impl PluginRequestProcessor { .await .map_err(Self::plugin_uninstall_error)?; match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => self.on_effective_plugins_changed(config), + Ok(_) => self.on_effective_plugins_changed(), Err(err) => { warn!( "failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}" @@ -1130,12 +1247,12 @@ impl PluginRequestProcessor { ) { let plugins_manager = self.thread_manager.plugins_manager(); if plugins_manager.clear_remote_installed_plugins_cache() { - self.on_effective_plugins_changed(config.clone()); + self.on_effective_plugins_changed(); } plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( &config.plugins_config_input(), auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); } @@ -1274,6 +1391,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS auth_policy: summary.auth_policy, availability: summary.availability, interface: summary.interface, + keywords: summary.keywords, } } @@ -1307,16 +1425,17 @@ fn remote_plugin_catalog_error_to_jsonrpc( err: RemotePluginCatalogError, context: &str, ) -> JSONRPCErrorError { - let code = match &err { + let message = format!("{context}: {err}"); + match &err { RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => { - INVALID_REQUEST_ERROR_CODE + invalid_request(message) } RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => { - INVALID_REQUEST_ERROR_CODE + invalid_request(message) } RemotePluginCatalogError::InvalidPluginPath { .. } | RemotePluginCatalogError::ArchiveTooLarge { .. } - | RemotePluginCatalogError::UnknownMarketplace { .. } => INVALID_REQUEST_ERROR_CODE, + | RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message), RemotePluginCatalogError::AuthToken(_) | RemotePluginCatalogError::Request { .. } | RemotePluginCatalogError::UnexpectedStatus { .. } @@ -1330,12 +1449,7 @@ fn remote_plugin_catalog_error_to_jsonrpc( | RemotePluginCatalogError::ArchiveJoin(_) | RemotePluginCatalogError::MissingUploadEtag | RemotePluginCatalogError::UnexpectedResponse(_) - | RemotePluginCatalogError::CacheRemove(_) => INTERNAL_ERROR_CODE, - }; - JSONRPCErrorError { - code, - message: format!("{context}: {err}"), - data: None, + | RemotePluginCatalogError::CacheRemove(_) => internal_error(message), } } diff --git a/codex-rs/app-server/src/request_processors/search.rs b/codex-rs/app-server/src/request_processors/search.rs index 85f1efe68d..d683c6f10a 100644 --- a/codex-rs/app-server/src/request_processors/search.rs +++ b/codex-rs/app-server/src/request_processors/search.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use crate::fuzzy_file_search::FuzzyFileSearchSession; use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::fuzzy_file_search::start_fuzzy_file_search_session; @@ -132,19 +132,3 @@ impl SearchRequestProcessor { Ok(FuzzyFileSearchSessionStopResponse {}) } } - -fn invalid_request(message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.into(), - data: None, - } -} - -fn internal_error(message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: message.into(), - data: None, - } -} diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 2e7c9909f0..ff192e34b2 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -7,7 +7,7 @@ pub(crate) struct ThreadGoalRequestProcessor { outgoing: Arc, config: Arc, thread_state_manager: ThreadStateManager, - state_db: Option, + state_db: StateDbHandle, } impl ThreadGoalRequestProcessor { @@ -16,7 +16,7 @@ impl ThreadGoalRequestProcessor { outgoing: Arc, config: Arc, thread_state_manager: ThreadStateManager, - state_db: Option, + state_db: StateDbHandle, ) -> Self { Self { thread_manager, @@ -72,23 +72,6 @@ impl ThreadGoalRequestProcessor { } } - pub(crate) async fn pending_resume_goal_state( - &self, - thread: &CodexThread, - ) -> (bool, Option) { - let emit_thread_goal_update = self.config.features.enabled(Feature::Goals); - let thread_goal_state_db = if emit_thread_goal_update { - if let Some(state_db) = thread.state_db() { - Some(state_db) - } else { - self.state_db.clone() - } - } else { - None - }; - (emit_thread_goal_update, thread_goal_state_db) - } - async fn thread_goal_set_inner( &self, request_id: ConnectionRequestId, @@ -110,7 +93,7 @@ impl ThreadGoalRequestProcessor { None => find_thread_path_by_id_str( &self.config.codex_home, &thread_id.to_string(), - self.state_db.as_deref(), + Some(self.state_db.as_ref()), ) .await .map_err(|err| { @@ -148,7 +131,7 @@ impl ThreadGoalRequestProcessor { thread.prepare_external_goal_mutation().await; } - let goal = (if let Some(objective) = objective { + let (goal, previous_status) = (if let Some(objective) = objective { let existing_goal = state_db .get_thread_goal(thread_id) .await @@ -157,6 +140,7 @@ impl ThreadGoalRequestProcessor { goal.objective == objective && goal.status != codex_state::ThreadGoalStatus::Complete }) { + let previous_status = ExternalGoalPreviousStatus::Existing(goal.status); state_db .update_thread_goal( thread_id, @@ -174,7 +158,9 @@ impl ThreadGoalRequestProcessor { ) }) }) + .map(|goal| (goal, previous_status)) } else { + let previous_status = ExternalGoalPreviousStatus::NewGoal; state_db .replace_thread_goal( thread_id, @@ -183,8 +169,19 @@ impl ThreadGoalRequestProcessor { params.token_budget.flatten(), ) .await + .map(|goal| (goal, previous_status)) } } else { + let existing_goal = state_db + .get_thread_goal(thread_id) + .await + .map_err(|err| invalid_request(err.to_string()))?; + let Some(existing_goal) = existing_goal else { + return Err(invalid_request(format!( + "cannot update goal for thread {thread_id}: no goal exists" + ))); + }; + let previous_status = ExternalGoalPreviousStatus::Existing(existing_goal.status); state_db .update_thread_goal( thread_id, @@ -200,9 +197,13 @@ impl ThreadGoalRequestProcessor { anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") }) }) + .map(|goal| (goal, previous_status)) }) .map_err(|err| invalid_request(err.to_string()))?; - let goal_status = goal.status; + let external_goal_set = ExternalGoalSet { + goal: goal.clone(), + previous_status, + }; let goal = api_thread_goal_from_state(goal); self.outgoing .send_response( @@ -213,7 +214,7 @@ impl ThreadGoalRequestProcessor { self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) .await; if let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_set(goal_status).await; + thread.apply_external_goal_set(external_goal_set).await; } Ok(()) } @@ -257,7 +258,7 @@ impl ThreadGoalRequestProcessor { None => find_thread_path_by_id_str( &self.config.codex_home, &thread_id.to_string(), - self.state_db.as_deref(), + Some(self.state_db.as_ref()), ) .await .map_err(|err| { @@ -321,7 +322,7 @@ impl ThreadGoalRequestProcessor { find_thread_path_by_id_str( &self.config.codex_home, &thread_id.to_string(), - self.state_db.as_deref(), + Some(self.state_db.as_ref()), ) .await .map_err(|err| { @@ -330,9 +331,7 @@ impl ThreadGoalRequestProcessor { .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?; } - self.state_db - .clone() - .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) + Ok(self.state_db.clone()) } async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { 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 9dabdea44f..ef44a2b178 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -8,7 +8,6 @@ pub(super) struct ListenerTaskContext { pub(super) thread_state_manager: ThreadStateManager, pub(super) outgoing: Arc, pub(super) pending_thread_unloads: Arc>>, - pub(super) analytics_events_client: AnalyticsEventsClient, pub(super) thread_watch_manager: ThreadWatchManager, pub(super) thread_list_state_permit: Arc, pub(super) fallback_model_provider: String, @@ -147,23 +146,17 @@ pub(super) async fn ensure_conversation_listener( { Ok(conv) => conv, Err(_) => { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {conversation_id}"), - data: None, - }); + return Err(invalid_request(format!( + "thread not found: {conversation_id}" + ))); } }; let thread_state = { let pending_thread_unloads = listener_task_context.pending_thread_unloads.lock().await; if pending_thread_unloads.contains(&conversation_id) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "thread {conversation_id} is closing; retry after the thread is closed" - ), - data: None, - }); + return Err(invalid_request(format!( + "thread {conversation_id} is closing; retry after the thread is closed" + ))); } let Some(thread_state) = listener_task_context .thread_state_manager @@ -229,13 +222,9 @@ pub(super) async fn ensure_listener_task_running( ) .await else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "thread {conversation_id} is closing; retry after the thread is closed" - ), - data: None, - }); + return Err(invalid_request(format!( + "thread {conversation_id} is closing; retry after the thread is closed" + ))); }; let (mut listener_command_rx, listener_generation) = { let mut thread_state = thread_state.lock().await; @@ -249,7 +238,6 @@ pub(super) async fn ensure_listener_task_running( thread_manager, thread_state_manager, pending_thread_unloads, - analytics_events_client, thread_watch_manager, thread_list_state_permit, fallback_model_provider, @@ -325,13 +313,11 @@ pub(super) async fn ensure_listener_task_running( conversation_id, conversation.clone(), thread_manager.clone(), - Some(analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), thread_list_state_permit.clone(), fallback_model_provider.clone(), - codex_home.as_path(), ) .await; } @@ -609,6 +595,8 @@ pub(super) async fn handle_pending_thread_resume_request( let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let active_permission_profile = thread_response_active_permission_profile(active_permission_profile); + let session_id = conversation.session_configured().session_id.to_string(); + thread.session_id = session_id; let response = ThreadResumeResponse { thread, @@ -702,7 +690,7 @@ pub(super) async fn send_thread_goal_snapshot_notification( } } -pub(super) fn populate_thread_turns_from_history( +pub(crate) fn populate_thread_turns_from_history( thread: &mut Thread, items: &[RolloutItem], active_turn: Option<&Turn>, 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 40be4f34ef..4042197a76 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2,6 +2,10 @@ use super::*; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; +const PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY: &str = + "persistExtendedHistory is deprecated and ignored"; +const PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS: &str = + "Remove this parameter. App-server always uses limited history persistence."; struct ThreadListFilters { model_providers: Option>, @@ -121,12 +125,6 @@ fn collect_resume_override_mismatches( "developerInstructions override was provided and ignored while running".to_string(), ); } - if request.persist_extended_history { - mismatch_details.push( - "persistExtendedHistory override was provided and ignored while running".to_string(), - ); - } - mismatch_details } @@ -165,10 +163,8 @@ fn normalize_thread_list_cwd_filters( for cwd in cwds { let cwd = AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) .map(AbsolutePathBuf::into_path_buf) - .map_err(|err| JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!("invalid thread/list cwd filter `{cwd}`: {err}"), - data: None, + .map_err(|err| { + invalid_params(format!("invalid thread/list cwd filter `{cwd}`: {err}")) })?; normalized_cwds.push(cwd); } @@ -188,6 +184,53 @@ fn has_model_resume_override( } fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { + const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128; + const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64; + const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$"; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + fn escape_identifier_for_error(value: &str) -> String { + value.escape_default().to_string() + } + + fn validate_dynamic_tool_identifier( + value: &str, + label: &str, + max_len: usize, + ) -> Result<(), String> { + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + if value.chars().count() > max_len { + return Err(format!( + "{label} must be at most {max_len} characters to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + Ok(()) + } + let mut seen = HashSet::new(); for tool in tools { let name = tool.name.trim(); @@ -197,9 +240,10 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if name != tool.name { return Err(format!( "dynamic tool name has leading/trailing whitespace: {}", - tool.name + escape_identifier_for_error(&tool.name), )); } + validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?; if name == "mcp" || name.starts_with("mcp__") { return Err(format!("dynamic tool name is reserved: {name}")); } @@ -213,13 +257,25 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if Some(namespace) != tool.namespace.as_deref() { return Err(format!( "dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}", + name = escape_identifier_for_error(name), + namespace = escape_identifier_for_error(namespace), )); } + validate_dynamic_tool_identifier( + namespace, + "dynamic tool namespace", + DYNAMIC_TOOL_NAMESPACE_MAX_LEN, + )?; if namespace == "mcp" || namespace.starts_with("mcp__") { return Err(format!( "dynamic tool namespace is reserved for {name}: {namespace}" )); } + if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) { + return Err(format!( + "dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}", + )); + } } if !seen.insert((namespace, name)) { if let Some(namespace) = namespace { @@ -249,7 +305,6 @@ pub(crate) struct ThreadRequestProcessor { pub(super) auth_manager: Arc, pub(super) thread_manager: Arc, pub(super) outgoing: Arc, - pub(super) analytics_events_client: AnalyticsEventsClient, pub(super) arg0_paths: Arg0DispatchPaths, pub(super) config: Arc, pub(super) config_manager: ConfigManager, @@ -269,7 +324,6 @@ impl ThreadRequestProcessor { auth_manager: Arc, thread_manager: Arc, outgoing: Arc, - analytics_events_client: AnalyticsEventsClient, arg0_paths: Arg0DispatchPaths, config: Arc, config_manager: ConfigManager, @@ -285,7 +339,6 @@ impl ThreadRequestProcessor { auth_manager, thread_manager, outgoing, - analytics_events_client, arg0_paths, config, config_manager, @@ -333,20 +386,34 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result, JSONRPCErrorError> { - self.thread_resume_inner(request_id, params) - .await - .map(|()| None) + self.thread_resume_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) } pub(crate) async fn thread_fork( &self, request_id: ConnectionRequestId, params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result, JSONRPCErrorError> { - self.thread_fork_inner(request_id, params) - .await - .map(|()| None) + self.thread_fork_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) } pub(crate) async fn thread_archive( @@ -395,10 +462,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, params: ThreadSetNameParams, ) -> Result, JSONRPCErrorError> { - match self - .thread_set_name_response_inner(&request_id, params) - .await - { + match self.thread_set_name_response_inner(params).await { Ok((response, notification)) => { self.outgoing .send_response(request_id.clone(), response) @@ -567,21 +631,14 @@ impl ThreadRequestProcessor { thread_id: &str, ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { // Resolve the core conversation handle from a v2 thread id string. - let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - })?; + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let thread = self .thread_manager .get_thread(thread_id) .await - .map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {thread_id}"), - data: None, - })?; + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; Ok((thread_id, thread)) } @@ -601,14 +658,18 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); thread - .set_app_server_client_info(app_server_client_name, app_server_client_version) + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to set app server client info: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) } async fn finalize_thread_teardown(&self, thread_id: ThreadId) { @@ -677,7 +738,6 @@ impl ThreadRequestProcessor { thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), - analytics_events_client: self.analytics_events_client.clone(), thread_watch_manager: self.thread_watch_manager.clone(), thread_list_state_permit: self.thread_list_state_permit.clone(), fallback_model_provider: self.config.model_provider_id.clone(), @@ -742,6 +802,7 @@ impl ThreadRequestProcessor { personality, ephemeral, session_start_source, + thread_source, environments, persist_extended_history, } = params; @@ -750,6 +811,10 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } + if persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } let environment_selections = self.parse_environment_selections(environments)?; let mut typesafe_overrides = self.build_thread_config_overrides( model, @@ -770,7 +835,6 @@ impl ThreadRequestProcessor { thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), - analytics_events_client: self.analytics_events_client.clone(), thread_watch_manager: self.thread_watch_manager.clone(), thread_list_state_permit: self.thread_list_state_permit.clone(), fallback_model_provider: self.config.model_provider_id.clone(), @@ -791,8 +855,8 @@ impl ThreadRequestProcessor { typesafe_overrides, dynamic_tools, session_start_source, + thread_source.map(Into::into), environment_selections, - persist_extended_history, service_name, experimental_raw_events, request_trace, @@ -841,6 +905,18 @@ impl ThreadRequestProcessor { self.outgoing.request_trace_context(request_id).await } + async fn send_persist_extended_history_deprecation_notice(&self, connection_id: ConnectionId) { + self.outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::DeprecationNotice(DeprecationNoticeNotification { + summary: PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY.to_string(), + details: Some(PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS.to_string()), + }), + ) + .await; + } + async fn submit_core_op( &self, request_id: &ConnectionRequestId, @@ -863,8 +939,8 @@ impl ThreadRequestProcessor { typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, session_start_source: Option, + thread_source: Option, environments: Option>, - persist_extended_history: bool, service_name: Option, experimental_raw_events: bool, request_trace: Option, @@ -980,8 +1056,9 @@ impl ThreadRequestProcessor { codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared, }, session_source: None, + thread_source, dynamic_tools: core_dynamic_tools, - persist_extended_history, + persist_extended_history: false, metrics_service_name: service_name, parent_trace: request_trace, environments, @@ -990,7 +1067,7 @@ impl ThreadRequestProcessor { "app_server.thread_start.create_thread", otel.name = "app_server.thread_start.create_thread", thread_start.dynamic_tool_count = core_dynamic_tool_count, - thread_start.persist_extended_history = persist_extended_history, + thread_start.persist_extended_history = false, )) .await .map_err(|err| match err { @@ -1014,6 +1091,7 @@ impl ThreadRequestProcessor { .await; let mut thread = build_thread_from_snapshot( thread_id, + session_configured.session_id.to_string(), &config_snapshot, session_configured.rollout_path.clone(), ); @@ -1105,7 +1183,7 @@ impl ThreadRequestProcessor { &self, model: Option, model_provider: Option, - service_tier: Option>, + service_tier: Option>, cwd: Option, approval_policy: Option, approvals_reviewer: Option, @@ -1314,7 +1392,6 @@ impl ThreadRequestProcessor { async fn thread_set_name_response_inner( &self, - request_id: &ConnectionRequestId, params: ThreadSetNameParams, ) -> Result<(ThreadSetNameResponse, Option), JSONRPCErrorError> { @@ -1326,13 +1403,6 @@ impl ThreadRequestProcessor { }; let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - self.submit_core_op(request_id, thread.as_ref(), Op::SetThreadName { name }) - .await - .map_err(|err| internal_error(format!("failed to set thread name: {err}")))?; - return Ok((ThreadSetNameResponse {}, None)); - } - self.thread_store .update_thread_metadata(StoreUpdateThreadMetadataParams { thread_id, @@ -1440,54 +1510,51 @@ impl ThreadRequestProcessor { return Err(invalid_request("gitInfo must include at least one field")); } - let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); - let mut state_db_ctx = loaded_thread.as_ref().and_then(|thread| thread.state_db()); - if state_db_ctx.is_none() { - state_db_ctx = self.state_db.clone(); - } - let Some(state_db_ctx) = state_db_ctx else { - return Err(internal_error(format!( - "sqlite state db unavailable for thread {thread_uuid}" - ))); - }; - - self.ensure_thread_metadata_row_exists(thread_uuid, &state_db_ctx, loaded_thread.as_ref()) - .await?; - let git_sha = Self::normalize_thread_metadata_git_field(sha, "gitInfo.sha")?; let git_branch = Self::normalize_thread_metadata_git_field(branch, "gitInfo.branch")?; let git_origin_url = Self::normalize_thread_metadata_git_field(origin_url, "gitInfo.originUrl")?; - let updated = state_db_ctx - .update_thread_git_info( - thread_uuid, - git_sha.as_ref().map(|value| value.as_deref()), - git_branch.as_ref().map(|value| value.as_deref()), - git_origin_url.as_ref().map(|value| value.as_deref()), - ) - .await - .map_err(|err| { - internal_error(format!( - "failed to update thread metadata for {thread_uuid}: {err}" - )) - })?; - if !updated { - return Err(internal_error(format!( - "thread metadata disappeared before update completed: {thread_uuid}" - ))); - } - - let Some(summary) = - read_summary_from_state_db_context_by_thread_id(Some(&state_db_ctx), thread_uuid).await - else { - return Err(internal_error(format!( - "failed to reload updated thread metadata for {thread_uuid}" - ))); + let patch = StoreThreadMetadataPatch { + git_info: Some(StoreGitInfoPatch { + sha: git_sha, + branch: git_branch, + origin_url: git_origin_url, + }), + ..Default::default() }; - let mut thread = summary_to_thread(summary, &self.config.cwd); + let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); + let updated_thread = { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + if let Some(loaded_thread) = loaded_thread.as_ref() { + if loaded_thread.config_snapshot().await.ephemeral { + return Err(invalid_request(format!( + "ephemeral thread does not support metadata updates: {thread_id}" + ))); + } + loaded_thread + .update_thread_metadata(patch, /*include_archived*/ true) + .await + } else { + self.thread_store + .update_thread_metadata(StoreUpdateThreadMetadataParams { + thread_id: thread_uuid, + patch, + include_archived: true, + }) + .await + } + .map_err(|err| thread_store_write_error("update thread metadata", err))? + }; + let (mut thread, _) = thread_from_stored_thread( + updated_thread, + self.config.model_provider_id.as_str(), + &self.config.cwd, + ); + if let Some(loaded_thread) = loaded_thread.as_ref() { + thread.session_id = loaded_thread.session_configured().session_id.to_string(); + } self.attach_thread_name(thread_uuid, &mut thread).await; thread.status = resolve_thread_status( self.thread_watch_manager @@ -1516,126 +1583,6 @@ impl ThreadRequestProcessor { } } - async fn ensure_thread_metadata_row_exists( - &self, - thread_uuid: ThreadId, - state_db_ctx: &Arc, - loaded_thread: Option<&Arc>, - ) -> Result<(), JSONRPCErrorError> { - match state_db_ctx.get_thread(thread_uuid).await { - Ok(Some(_)) => return Ok(()), - Ok(None) => {} - Err(err) => { - return Err(internal_error(format!( - "failed to load thread metadata for {thread_uuid}: {err}" - ))); - } - } - - if let Some(thread) = loaded_thread { - let Some(rollout_path) = thread.rollout_path() else { - return Err(invalid_request(format!( - "ephemeral thread does not support metadata updates: {thread_uuid}" - ))); - }; - - reconcile_rollout( - Some(state_db_ctx), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - - match state_db_ctx.get_thread(thread_uuid).await { - Ok(Some(_)) => return Ok(()), - Ok(None) => {} - Err(err) => { - return Err(internal_error(format!( - "failed to load reconciled thread metadata for {thread_uuid}: {err}" - ))); - } - } - - let config_snapshot = thread.config_snapshot().await; - let model_provider = config_snapshot.model_provider_id.clone(); - let mut builder = ThreadMetadataBuilder::new( - thread_uuid, - rollout_path, - Utc::now(), - config_snapshot.session_source.clone(), - ); - builder.model_provider = Some(model_provider.clone()); - builder.cwd = config_snapshot.cwd.to_path_buf(); - builder.cli_version = Some(env!("CARGO_PKG_VERSION").to_string()); - builder.sandbox_policy = config_snapshot.sandbox_policy(); - builder.approval_mode = config_snapshot.approval_policy; - let metadata = builder.build(model_provider.as_str()); - if let Err(err) = state_db_ctx.insert_thread_if_absent(&metadata).await { - return Err(internal_error(format!( - "failed to create thread metadata for {thread_uuid}: {err}" - ))); - } - return Ok(()); - } - - let rollout_path = match find_thread_path_by_id_str( - &self.config.codex_home, - &thread_uuid.to_string(), - self.state_db.as_deref(), - ) - .await - { - Ok(Some(path)) => path, - Ok(None) => match find_archived_thread_path_by_id_str( - &self.config.codex_home, - &thread_uuid.to_string(), - self.state_db.as_deref(), - ) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - return Err(invalid_request(format!("thread not found: {thread_uuid}"))); - } - Err(err) => { - return Err(internal_error(format!( - "failed to locate archived thread id {thread_uuid}: {err}" - ))); - } - }, - Err(err) => { - return Err(internal_error(format!( - "failed to locate thread id {thread_uuid}: {err}" - ))); - } - }; - - reconcile_rollout( - Some(state_db_ctx), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - - match state_db_ctx.get_thread(thread_uuid).await { - Ok(Some(_)) => Ok(()), - Ok(None) => Err(internal_error(format!( - "failed to create thread metadata from rollout for {thread_uuid}" - ))), - Err(err) => Err(internal_error(format!( - "failed to load reconciled thread metadata for {thread_uuid}: {err}" - ))), - } - } - async fn thread_unarchive_inner( &self, params: ThreadUnarchiveParams, @@ -1653,20 +1600,16 @@ impl ThreadRequestProcessor { .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let fallback_provider = self.config.model_provider_id.clone(); - let mut thread = self + let stored_thread = self .thread_store .unarchive_thread(StoreArchiveThreadParams { thread_id }) .await - .map_err(|err| thread_store_archive_error("unarchive", err)) - .and_then(|stored_thread| { - summary_from_stored_thread(stored_thread, fallback_provider.as_str()) - .map(|summary| summary_to_thread(summary, &self.config.cwd)) - .ok_or_else(|| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to read unarchived thread {thread_id}"), - data: None, - }) + .map_err(|err| thread_store_archive_error("unarchive", err))?; + let summary = summary_from_stored_thread(stored_thread, fallback_provider.as_str()) + .ok_or_else(|| { + internal_error(format!("failed to read unarchived thread {thread_id}")) })?; + let mut thread = summary_to_thread(summary, &self.config.cwd); thread.status = resolve_thread_status( self.thread_watch_manager @@ -2092,6 +2035,7 @@ impl ThreadRequestProcessor { if thread.path.is_none() { thread.path = fallback_thread.path.clone(); } + thread.session_id.clone_from(&fallback_thread.session_id); thread.ephemeral = fallback_thread.ephemeral; thread } else { @@ -2275,8 +2219,12 @@ impl ThreadRequestProcessor { ) { if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { let config_snapshot = thread.config_snapshot().await; - let loaded_thread = - build_thread_from_snapshot(thread_id, &config_snapshot, thread.rollout_path()); + let loaded_thread = build_thread_from_snapshot( + thread_id, + thread.session_configured().session_id.to_string(), + &config_snapshot, + thread.rollout_path(), + ); self.thread_watch_manager.upsert_thread(loaded_thread).await; } @@ -2299,6 +2247,8 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2327,6 +2277,10 @@ impl ThreadRequestProcessor { .await; return Ok(()); } + if params.persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await { Ok(permit) => permit, @@ -2335,7 +2289,15 @@ impl ThreadRequestProcessor { return Ok(()); } }; - match self.resume_running_thread(&request_id, ¶ms).await { + match self + .resume_running_thread( + &request_id, + ¶ms, + app_server_client_name.clone(), + app_server_client_version.clone(), + ) + .await + { Ok(true) => return Ok(()), Ok(false) => {} Err(error) => { @@ -2361,7 +2323,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, exclude_turns, - persist_extended_history, + persist_extended_history: _persist_extended_history, } = params; let include_turns = !exclude_turns; @@ -2425,7 +2387,7 @@ impl ThreadRequestProcessor { config.clone(), thread_history, self.auth_manager.clone(), - persist_extended_history, + /*persist_extended_history*/ false, self.request_trace_context(&request_id).await, ) .await @@ -2436,6 +2398,16 @@ impl ThreadRequestProcessor { session_configured, .. }) => { + if let Err(err) = Self::set_app_server_client_info( + codex_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await + { + self.outgoing.send_error(request_id, err).await; + return Ok(()); + } let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2475,6 +2447,11 @@ impl ThreadRequestProcessor { return Ok(()); } }; + thread.thread_source = codex_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); self.thread_watch_manager .upsert_thread(thread.clone()) @@ -2572,6 +2549,8 @@ impl ThreadRequestProcessor { &self, request_id: &ConnectionRequestId, params: &ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result { let running_thread = if params.history.is_some() { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) @@ -2653,6 +2632,12 @@ impl ThreadRequestProcessor { thread_state.clone(), ) .await?; + Self::set_app_server_client_info( + existing_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); @@ -2665,11 +2650,12 @@ impl ThreadRequestProcessor { } let mut summary_source_thread = source_thread; summary_source_thread.history = None; - let thread_summary = self.stored_thread_to_api_thread( + let mut thread_summary = self.stored_thread_to_api_thread( summary_source_thread, config_snapshot.model_provider_id.as_str(), /*include_turns*/ false, ); + thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); let mut config_for_instruction_sources = self.config.as_ref().clone(); config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); let instruction_sources = @@ -2685,10 +2671,10 @@ impl ThreadRequestProcessor { ))); }; - let (emit_thread_goal_update, thread_goal_state_db) = self - .thread_goal_processor - .pending_resume_goal_state(existing_thread.as_ref()) - .await; + let emit_thread_goal_update = self.config.features.enabled(Feature::Goals); + let thread_goal_state_db = emit_thread_goal_update + .then(|| self.state_db.clone()) + .flatten(); let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( Box::new(crate::thread_state::PendingThreadResumeRequest { @@ -2838,6 +2824,7 @@ impl ThreadRequestProcessor { include_turns: bool, ) -> std::result::Result { let config_snapshot = thread.config_snapshot().await; + let session_id = thread.session_configured().session_id.to_string(); let thread = match thread_history { InitialHistory::Resumed(resumed) => { let fallback_provider = config_snapshot.model_provider_id.as_str(); @@ -2899,6 +2886,7 @@ impl ThreadRequestProcessor { InitialHistory::Forked(items) => { let mut thread = build_thread_from_snapshot( thread_id, + session_id.clone(), &config_snapshot, Some(rollout_path.into()), ); @@ -2911,6 +2899,7 @@ impl ThreadRequestProcessor { }; let mut thread = thread?; thread.id = thread_id.to_string(); + thread.session_id = session_id; thread.path = Some(rollout_path.to_path_buf()); if include_turns { let history_items = thread_history.get_rollout_items(); @@ -2936,6 +2925,8 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -2952,6 +2943,7 @@ impl ThreadRequestProcessor { base_instructions, developer_instructions, ephemeral, + thread_source, exclude_turns, persist_extended_history, } = params; @@ -2961,6 +2953,10 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } + if persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } let source_thread = self .read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true) @@ -3038,7 +3034,8 @@ impl ThreadRequestProcessor { history: history_items.clone(), rollout_path: source_thread.rollout_path.clone(), }), - persist_extended_history, + thread_source.map(Into::into), + /*persist_extended_history*/ false, self.request_trace_context(&request_id).await, ) .await @@ -3050,6 +3047,13 @@ impl ThreadRequestProcessor { err => internal_error(format!("error forking thread: {err}")), })?; + Self::set_app_server_client_info( + forked_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( self.ensure_conversation_listener( @@ -3077,8 +3081,12 @@ impl ThreadRequestProcessor { } else { let config_snapshot = forked_thread.config_snapshot().await; // forked thread names do not inherit the source thread name - let mut thread = - build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None); + let mut thread = build_thread_from_snapshot( + thread_id, + session_configured.session_id.to_string(), + &config_snapshot, + /*path*/ None, + ); thread.preview = preview_from_rollout_items(&history_items); thread.forked_from_id = Some(source_thread_id.to_string()); if include_turns { @@ -3090,6 +3098,12 @@ impl ThreadRequestProcessor { } thread }; + thread.session_id = session_configured.session_id.to_string(); + thread.thread_source = forked_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); self.thread_watch_manager .upsert_thread_silently(thread.clone()) @@ -3131,23 +3145,10 @@ impl ThreadRequestProcessor { // `excludeTurns` is the cheap fork path, so skip restored usage replay // instead of rebuilding history only to attribute a historical update. if let Some(token_usage_thread) = token_usage_thread { - let token_usage_turn_id = if let Some(rollout_path) = token_usage_thread.path.as_deref() - { - read_rollout_items_from_rollout(rollout_path) - .await - .ok() - .and_then(|rollout_items| { - latest_token_usage_turn_id_from_rollout_items( - &rollout_items, - token_usage_thread.turns.as_slice(), - ) - }) - } else { - latest_token_usage_turn_id_from_rollout_items( - &history_items, - token_usage_thread.turns.as_slice(), - ) - }; + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &history_items, + token_usage_thread.turns.as_slice(), + ); // Mirror the resume contract for forks: the new thread is usable as soon // as the response arrives, so restored usage must follow immediately. send_thread_token_usage_update_to_connection( @@ -3319,6 +3320,17 @@ impl ThreadRequestProcessor { } } +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} + const THREAD_TURNS_DEFAULT_LIMIT: usize = 25; const THREAD_TURNS_MAX_LIMIT: usize = 100; @@ -3377,11 +3389,9 @@ fn paginate_thread_turns( .as_ref() .and_then(|anchor| turns.iter().position(|turn| turn.id == anchor.turn_id)); if anchor.is_some() && anchor_index.is_none() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "invalid cursor: anchor turn is no longer present".to_string(), - data: None, - }); + return Err(invalid_request( + "invalid cursor: anchor turn is no longer present", + )); } let mut keyed_turns: Vec<_> = turns.into_iter().enumerate().collect(); @@ -3442,19 +3452,11 @@ fn serialize_thread_turns_cursor( turn_id: turn_id.to_string(), include_anchor, }) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to serialize cursor: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to serialize cursor: {err}"))) } fn parse_thread_turns_cursor(cursor: &str) -> Result { - serde_json::from_str(cursor).map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }) + serde_json::from_str(cursor).map_err(|_| invalid_request(format!("invalid cursor: {cursor}"))) } fn reconstruct_thread_turns_for_turns_list( @@ -3505,36 +3507,18 @@ fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError { fn thread_store_list_error(err: ThreadStoreError) -> JSONRPCErrorError { match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to list threads: {err}"), - data: None, - }, + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + err => internal_error(format!("failed to list threads: {err}")), } } fn thread_store_resume_read_error(err: ThreadStoreError) -> JSONRPCErrorError { match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - ThreadStoreError::ThreadNotFound { thread_id } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("no rollout found for thread id {thread_id}"), - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to read thread: {err}"), - data: None, - }, + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::ThreadNotFound { thread_id } => { + invalid_request(format!("no rollout found for thread id {thread_id}")) + } + err => internal_error(format!("failed to read thread: {err}")), } } @@ -3597,25 +3581,17 @@ fn conversation_summary_thread_id_read_error( ThreadStoreError::ThreadNotFound { thread_id } if thread_id == conversation_id => { conversation_summary_not_found_error(conversation_id) } - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to load conversation summary for {conversation_id}: {err}"), - data: None, - }, + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + err => internal_error(format!( + "failed to load conversation summary for {conversation_id}: {err}" + )), } } fn conversation_summary_not_found_error(conversation_id: ThreadId) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("no rollout found for conversation id {conversation_id}"), - data: None, - } + invalid_request(format!( + "no rollout found for conversation id {conversation_id}" + )) } fn conversation_summary_rollout_path_read_error( @@ -3623,71 +3599,32 @@ fn conversation_summary_rollout_path_read_error( err: ThreadStoreError, ) -> JSONRPCErrorError { match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to load conversation summary from {}: {}", - path.display(), - err - ), - data: None, - }, + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + err => internal_error(format!( + "failed to load conversation summary from {}: {}", + path.display(), + err + )), } } fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { match err { - ThreadStoreError::ThreadNotFound { thread_id } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {thread_id}"), - data: None, - }, - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to {operation}: {err}"), - data: None, - }, + ThreadStoreError::ThreadNotFound { thread_id } => { + invalid_request(format!("thread not found: {thread_id}")) + } + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + err => internal_error(format!("failed to {operation}: {err}")), } } fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to {operation} thread: {err}"), - data: None, - }, + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + err => internal_error(format!("failed to {operation} thread: {err}")), } } -async fn read_summary_from_state_db_context_by_thread_id( - state_db_ctx: Option<&StateDbHandle>, - thread_id: ThreadId, -) -> Option { - let state_db_ctx = state_db_ctx?; - - let metadata = match state_db_ctx.get_thread(thread_id).await { - Ok(Some(metadata)) => metadata, - Ok(None) | Err(_) => return None, - }; - Some(summary_from_thread_metadata(&metadata)) -} - async fn title_from_state_db( config: &Config, state_db_ctx: Option<&StateDbHandle>, @@ -3726,7 +3663,7 @@ fn set_thread_name_from_title(thread: &mut Thread, title: String) { thread.name = Some(title); } -fn thread_from_stored_thread( +pub(crate) fn thread_from_stored_thread( thread: StoredThread, fallback_provider: &str, fallback_cwd: &AbsolutePathBuf, @@ -3750,8 +3687,10 @@ fn thread_from_stored_thread( thread.agent_role.clone(), ); let history = thread.history; + let thread_id = thread.thread_id.to_string(); let thread = Thread { - id: thread.thread_id.to_string(), + id: thread_id.clone(), + session_id: thread_id, forked_from_id: thread.forked_from_id.map(|id| id.to_string()), preview: thread.first_user_message.unwrap_or(thread.preview), ephemeral: false, @@ -3769,6 +3708,7 @@ fn thread_from_stored_thread( agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), + thread_source: thread.thread_source.map(Into::into), git_info, name: thread.name, turns: Vec::new(), @@ -3820,6 +3760,7 @@ fn summary_from_stored_thread( } #[allow(clippy::too_many_arguments)] +#[cfg(test)] fn summary_from_state_db_metadata( conversation_id: ThreadId, path: PathBuf, @@ -3830,6 +3771,7 @@ fn summary_from_state_db_metadata( cwd: PathBuf, cli_version: String, source: String, + _thread_source: Option, agent_nickname: Option, agent_role: Option, git_sha: Option, @@ -3864,6 +3806,7 @@ fn summary_from_state_db_metadata( } } +#[cfg(test)] fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummary { summary_from_state_db_metadata( metadata.id, @@ -3879,6 +3822,7 @@ fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummar metadata.cwd.clone(), metadata.cli_version.clone(), metadata.source.clone(), + metadata.thread_source, metadata.agent_nickname.clone(), metadata.agent_role.clone(), metadata.git_sha.clone(), @@ -3943,12 +3887,14 @@ fn permission_profile_trusts_project( fn build_thread_from_snapshot( thread_id: ThreadId, + session_id: String, config_snapshot: &ThreadConfigSnapshot, path: Option, ) -> Thread { let now = time::OffsetDateTime::now_utc().unix_timestamp(); Thread { id: thread_id.to_string(), + session_id, forked_from_id: None, preview: String::new(), ephemeral: config_snapshot.ephemeral, @@ -3962,6 +3908,7 @@ fn build_thread_from_snapshot( agent_nickname: config_snapshot.session_source.get_nickname(), agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), + thread_source: config_snapshot.thread_source.map(Into::into), git_info: None, name: None, turns: Vec::new(), @@ -3973,7 +3920,12 @@ fn build_thread_from_loaded_snapshot( config_snapshot: &ThreadConfigSnapshot, loaded_thread: &CodexThread, ) -> Thread { - build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path()) + build_thread_from_snapshot( + thread_id, + loaded_thread.session_configured().session_id.to_string(), + config_snapshot, + loaded_thread.rollout_path(), + ) } #[cfg(test)] 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 14859d5938..5a18c09db9 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 @@ -52,6 +52,7 @@ mod thread_processor_behavior_tests { use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::ServerRequestPayload; + use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; @@ -70,6 +71,7 @@ mod thread_processor_behavior_tests { use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; + use codex_state::ThreadMetadataBuilder; use codex_thread_store::StoredThread; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -154,6 +156,22 @@ mod thread_processor_behavior_tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn validate_dynamic_tools_accepts_responses_compatible_identifiers() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("Codex-App_2".to_string()), + name: "lookup-ticket_2".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + #[test] fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() { let tools = vec![ @@ -204,6 +222,7 @@ mod thread_processor_behavior_tests { text_elements: Vec::new(), }], }], + items_view: TurnItemsView::Full, error: None, status: TurnStatus::InProgress, started_at: None, @@ -257,6 +276,104 @@ mod thread_processor_behavior_tests { assert!(err.contains("reserved"), "unexpected error: {err}"); } + #[test] + fn validate_dynamic_tools_rejects_name_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "lookup.ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid name"); + assert!(err.contains("lookup.ticket"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid namespace"); + assert!(err.contains("codex.app"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() { + let long_name = "a".repeat(129); + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: long_name.clone(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("name too long"); + assert!(err.contains("at most 128"), "unexpected error: {err}"); + assert!(err.contains(&long_name), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() { + let long_namespace = "a".repeat(65); + let tools = vec![ApiDynamicToolSpec { + namespace: Some(long_namespace.clone()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("namespace too long"); + assert!(err.contains("at most 64"), "unexpected error: {err}"); + assert!(err.contains(&long_namespace), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_responses_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("functions".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace"); + assert!(err.contains("functions"), "unexpected error: {err}"); + assert!(err.contains("Responses API"), "unexpected error: {err}"); + } + #[test] fn summary_from_stored_thread_preserves_millisecond_precision() { let created_at = @@ -280,6 +397,7 @@ mod thread_processor_behavior_tests { cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: Some(codex_protocol::protocol::ThreadSource::User), agent_nickname: None, agent_role: None, agent_path: None, @@ -511,7 +629,7 @@ mod thread_processor_behavior_tests { path: None, model: None, model_provider: None, - service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)), + service_tier: Some(Some("priority".to_string())), cwd: None, approval_policy: None, approvals_reviewer: None, @@ -527,7 +645,7 @@ mod thread_processor_behavior_tests { let config_snapshot = ThreadConfigSnapshot { model: "gpt-5".to_string(), model_provider_id: "openai".to_string(), - service_tier: Some(codex_protocol::config_types::ServiceTier::Flex), + service_tier: Some("flex".to_string()), approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, @@ -537,11 +655,12 @@ mod thread_processor_behavior_tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + thread_source: None, }; assert_eq!( collect_resume_override_mismatches(&request, &config_snapshot), - vec!["service_tier requested=Some(Fast) active=Some(Flex)".to_string()] + vec!["service_tier requested=Some(\"priority\") active=Some(\"flex\")".to_string()] ); } @@ -825,6 +944,7 @@ mod thread_processor_behavior_tests { agent_nickname: None, agent_role: None, }), + thread_source: Some(codex_protocol::protocol::ThreadSource::Subagent), agent_nickname: Some("atlas".to_string()), agent_role: Some("explorer".to_string()), model_provider: Some("test-provider".to_string()), @@ -846,6 +966,7 @@ mod thread_processor_behavior_tests { assert_eq!(thread.agent_nickname, Some("atlas".to_string())); assert_eq!(thread.agent_role, Some("explorer".to_string())); + assert_eq!(thread.thread_source, None); Ok(()) } @@ -972,6 +1093,7 @@ mod thread_processor_behavior_tests { PathBuf::from("/"), "0.0.0".to_string(), source, + Some(codex_protocol::protocol::ThreadSource::Subagent), Some("atlas".to_string()), Some("explorer".to_string()), /*git_sha*/ None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index ed2360ed13..f2de590a9a 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -1,5 +1,6 @@ use super::*; +#[cfg(test)] pub(crate) async fn read_summary_from_rollout( path: &Path, fallback_provider: &str, @@ -74,18 +75,7 @@ pub(crate) async fn read_summary_from_rollout( }) } -pub(crate) async fn read_rollout_items_from_rollout( - path: &Path, -) -> std::io::Result> { - let items = match RolloutRecorder::get_rollout_history(path).await? { - InitialHistory::New | InitialHistory::Cleared => Vec::new(), - InitialHistory::Forked(items) => items, - InitialHistory::Resumed(resumed) => resumed.history, - }; - - Ok(items) -} - +#[cfg(test)] fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], @@ -134,6 +124,7 @@ fn extract_conversation_summary( }) } +#[cfg(test)] fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { ConversationGitInfo { sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()), @@ -220,6 +211,7 @@ fn parse_datetime(timestamp: Option<&str>) -> Option> { }) } +#[cfg(test)] async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option { let updated_at = tokio::fs::metadata(path) .await @@ -271,8 +263,10 @@ pub(crate) fn summary_to_thread( fallback_cwd.clone() }); + let thread_id = conversation_id.to_string(); Thread { - id: conversation_id.to_string(), + id: thread_id.clone(), + session_id: thread_id, forked_from_id: None, preview, ephemeral: false, @@ -286,6 +280,7 @@ pub(crate) fn summary_to_thread( agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), + thread_source: None, git_info, name: None, turns: Vec::new(), 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 75b07d0e47..b19c4a61a0 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 @@ -112,3 +112,62 @@ fn latest_token_usage_turn_id(thread: &Thread) -> String { .map(|turn| turn.id.clone()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::build_turns_from_rollout_items; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::TokenCountEvent; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + + #[test] + fn replay_attribution_uses_already_loaded_history() { + let rollout_items = token_usage_history(); + let turns = build_turns_from_rollout_items(&rollout_items); + + assert_eq!( + latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns.as_slice()), + Some(turns[0].id.clone()) + ); + } + + #[test] + fn replay_attribution_falls_back_to_rebuilt_turn_position() { + let rollout_items = token_usage_history(); + let mut turns = build_turns_from_rollout_items(&rollout_items); + turns[0].id = "rebuilt-turn-id".to_string(); + + assert_eq!( + latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns.as_slice()), + Some("rebuilt-turn-id".to_string()) + ); + } + + fn token_usage_history() -> Vec { + vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "first turn".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "first answer".to_string(), + phase: None, + memory_citation: None, + })), + RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent { + info: None, + rate_limits: None, + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "second turn".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + ] + } +} 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 c11b7166e2..bdc5847b0d 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -13,7 +13,6 @@ pub(crate) struct TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, - state_db: Option, } impl TurnRequestProcessor { @@ -30,7 +29,6 @@ impl TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, - state_db: Option, ) -> Self { Self { auth_manager, @@ -44,7 +42,6 @@ impl TurnRequestProcessor { thread_state_manager, thread_watch_manager, thread_list_state_permit, - state_db, } } @@ -174,21 +171,14 @@ impl TurnRequestProcessor { thread_id: &str, ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { // Resolve the core conversation handle from a v2 thread id string. - let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - })?; + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let thread = self .thread_manager .get_thread(thread_id) .await - .map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {thread_id}"), - data: None, - })?; + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; Ok((thread_id, thread)) } @@ -212,14 +202,6 @@ impl TurnRequestProcessor { fn review_request_from_target( target: ApiReviewTarget, ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { - fn invalid_request(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - } - } - let cleaned_target = match target { ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, ApiReviewTarget::BaseBranch { branch } => { @@ -308,17 +290,15 @@ impl TurnRequestProcessor { } fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!( - "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." - ), - data: Some(serde_json::json!({ - "input_error_code": INPUT_TOO_LARGE_ERROR_CODE, - "max_chars": MAX_USER_INPUT_TEXT_CHARS, - "actual_chars": actual_chars, - })), - } + let mut error = invalid_params(format!( + "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." + )); + error.data = Some(serde_json::json!({ + "input_error_code": INPUT_TOO_LARGE_ERROR_CODE, + "max_chars": MAX_USER_INPUT_TEXT_CHARS, + "actual_chars": actual_chars, + })); + error } fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { @@ -458,7 +438,7 @@ impl TurnRequestProcessor { model: model.clone(), effort, summary, - service_tier, + service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), personality, }) @@ -522,6 +502,7 @@ impl TurnRequestProcessor { let turn = Turn { id: turn_id, items: vec![], + items_view: TurnItemsView::NotLoaded, error: None, status: TurnStatus::InProgress, started_at: None, @@ -564,14 +545,18 @@ impl TurnRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); thread - .set_app_server_client_info(app_server_client_name, app_server_client_version) + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to set app server client info: {err}"), - data: None, - }) + .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) } async fn turn_steer_inner( @@ -615,9 +600,8 @@ impl TurnRequestProcessor { ) .await .map_err(|err| { - let (code, message, data, error_type) = match err { + let (message, data, error_type) = match err { SteerInputError::NoActiveTurn(_) => ( - INVALID_REQUEST_ERROR_CODE, "no active turn to steer".to_string(), None, Some(AnalyticsJsonRpcError::TurnSteer( @@ -625,7 +609,6 @@ impl TurnRequestProcessor { )), ), SteerInputError::ExpectedTurnMismatch { expected, actual } => ( - INVALID_REQUEST_ERROR_CODE, format!("expected active turn id `{expected}` but found `{actual}`"), None, Some(AnalyticsJsonRpcError::TurnSteer( @@ -661,24 +644,19 @@ impl TurnRequestProcessor { } }; ( - INVALID_REQUEST_ERROR_CODE, message, data, Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), ) } SteerInputError::EmptyInput => ( - INVALID_REQUEST_ERROR_CODE, "input must not be empty".to_string(), None, Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), }; - let error = JSONRPCErrorError { - code, - message, - data, - }; + let mut error = invalid_request(message); + error.data = data; self.track_error_response(request_id, &error, error_type); error })?; @@ -838,6 +816,7 @@ impl TurnRequestProcessor { Turn { id: turn_id, items, + items_view: TurnItemsView::NotLoaded, error: None, status: TurnStatus::InProgress, started_at: None, @@ -891,24 +870,20 @@ impl TurnRequestProcessor { review_request: ReviewRequest, display_text: &str, ) -> std::result::Result<(), JSONRPCErrorError> { - let rollout_path = if let Some(path) = parent_thread.rollout_path() { - path - } else { - find_thread_path_by_id_str( - &self.config.codex_home, - &parent_thread_id.to_string(), - self.state_db.as_deref(), - ) + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await.map_err(|err| { + internal_error(format!( + "failed to flush parent thread {parent_thread_id}: {err}" + )) + })?; + let parent_history = parent_thread + .load_history(/*include_archived*/ true) .await .map_err(|err| { internal_error(format!( - "failed to locate thread id {parent_thread_id}: {err}" + "failed to load parent thread {parent_thread_id}: {err}" )) - })? - .ok_or_else(|| { - invalid_request(format!("no rollout found for thread id {parent_thread_id}")) - })? - }; + })?; let mut config = self.config.as_ref().clone(); if let Some(review_model) = &config.review_model { @@ -918,14 +893,18 @@ impl TurnRequestProcessor { let NewThread { thread_id, thread: review_thread, - session_configured, .. } = self .thread_manager - .fork_thread( + .fork_thread_from_history( ForkSnapshot::Interrupted, config.clone(), - rollout_path, + InitialHistory::Resumed(ResumedHistory { + conversation_id: parent_thread_id, + history: parent_history.items, + rollout_path: parent_thread.rollout_path(), + }), + /*thread_source*/ None, /*persist_extended_history*/ false, self.request_trace_context(request_id).await, ) @@ -947,37 +926,33 @@ impl TurnRequestProcessor { ); let fallback_provider = self.config.model_provider_id.as_str(); - if let Some(rollout_path) = review_thread.rollout_path() { - match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await { - Ok(summary) => { - let mut thread = summary_to_thread(summary, &self.config.cwd); + match review_thread + .read_thread( + /*include_archived*/ true, /*include_history*/ false, + ) + .await + { + Ok(stored_thread) => { + let (mut thread, _) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + thread.session_id = review_thread.session_configured().session_id.to_string(); + self.thread_watch_manager + .upsert_thread_silently(thread.clone()) + .await; + thread.status = resolve_thread_status( self.thread_watch_manager - .upsert_thread_silently(thread.clone()) - .await; - thread.status = resolve_thread_status( - self.thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await, - /*has_in_progress_turn*/ false, - ); - let notif = thread_started_notification(thread); - self.outgoing - .send_server_notification(ServerNotification::ThreadStarted(notif)) - .await; - } - Err(err) => { - tracing::warn!( - "failed to load summary for review thread {}: {}", - session_configured.session_id, - err - ); - } + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + let notif = thread_started_notification(thread); + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + } + Err(err) => { + tracing::warn!("failed to load summary for review thread {thread_id}: {err}"); } - } else { - tracing::warn!( - "review thread {} has no rollout path", - session_configured.session_id - ); } let turn_id = self @@ -1018,7 +993,7 @@ impl TurnRequestProcessor { request_id, parent_thread, review_request, - display_text.as_str(), + &display_text, thread_id, ) .await?; @@ -1029,7 +1004,7 @@ impl TurnRequestProcessor { parent_thread_id, parent_thread, review_request, - display_text.as_str(), + &display_text, ) .await?; } @@ -1108,7 +1083,6 @@ impl TurnRequestProcessor { thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), - analytics_events_client: self.analytics_events_client.clone(), thread_watch_manager: self.thread_watch_manager.clone(), thread_list_state_permit: self.thread_list_state_permit.clone(), fallback_model_provider: self.config.model_provider_id.clone(), @@ -1131,3 +1105,14 @@ impl TurnRequestProcessor { .await } } + +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs index f380d46653..2392cc8078 100644 --- a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -20,6 +20,12 @@ impl WindowsSandboxRequestProcessor { } } + pub(crate) async fn windows_sandbox_readiness( + &self, + ) -> Result { + Ok(determine_windows_sandbox_readiness(&self.config)) + } + pub(crate) async fn windows_sandbox_setup_start( &self, request_id: &ConnectionRequestId, @@ -101,3 +107,80 @@ impl WindowsSandboxRequestProcessor { Ok(()) } } + +fn determine_windows_sandbox_readiness(config: &Config) -> WindowsSandboxReadinessResponse { + if !cfg!(windows) { + return WindowsSandboxReadinessResponse { + status: WindowsSandboxReadiness::NotConfigured, + }; + } + + determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::from_config(config), + sandbox_setup_is_complete(config.codex_home.as_path()), + ) +} + +fn determine_windows_sandbox_readiness_from_state( + windows_sandbox_level: WindowsSandboxLevel, + sandbox_setup_is_complete: bool, +) -> WindowsSandboxReadinessResponse { + let status = match windows_sandbox_level { + WindowsSandboxLevel::Disabled => WindowsSandboxReadiness::NotConfigured, + WindowsSandboxLevel::RestrictedToken => WindowsSandboxReadiness::Ready, + WindowsSandboxLevel::Elevated => { + if sandbox_setup_is_complete { + WindowsSandboxReadiness::Ready + } else { + WindowsSandboxReadiness::UpdateRequired + } + } + }; + + WindowsSandboxReadinessResponse { status } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn determine_windows_sandbox_readiness_reports_not_configured_when_disabled() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Disabled, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::NotConfigured); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_ready_for_unelevated_mode() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::RestrictedToken, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::Ready); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_ready_for_complete_elevated_mode() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Elevated, + /*sandbox_setup_is_complete*/ true, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::Ready); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_update_required_when_elevated_setup_is_stale() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Elevated, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::UpdateRequired); + } +} diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index b1373c293d..7315a13c02 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -889,6 +889,7 @@ mod tests { fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -902,6 +903,7 @@ mod tests { agent_nickname: None, agent_role: None, source, + thread_source: None, git_info: None, name: None, turns: Vec::new(), diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 3b4a58a7ab..be7d5d047f 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -29,6 +29,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { supported_in_api: preset.supported_in_api, priority, additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset.service_tiers.clone(), upgrade: preset.upgrade.as_ref().map(Into::into), base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 06b273754c..6b2a9a0abe 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -138,6 +138,7 @@ pub fn create_fake_rollout_with_source( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -221,6 +222,7 @@ pub fn create_fake_rollout_with_text_elements( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 862721a154..c6f95af95d 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -170,6 +170,7 @@ pub(crate) fn assert_basic_thread_initialized_event( thread_id: &str, expected_model: &str, initialization_mode: &str, + expected_thread_source: &str, ) { assert_eq!(event["event_params"]["thread_id"], thread_id); assert_eq!( @@ -186,7 +187,10 @@ pub(crate) fn assert_basic_thread_initialized_event( ); assert_eq!(event["event_params"]["model"], expected_model); assert_eq!(event["event_params"]["ephemeral"], false); - assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!( + event["event_params"]["thread_source"], + expected_thread_source + ); assert_eq!( event["event_params"]["subagent_source"], serde_json::Value::Null 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 7ee21a2068..b357e139db 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> { + let server = MockServer::start().await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup.ticket".to_string(), + description: "Invalid dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(thread_req)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert!(error.error.message.contains("Responses API")); + assert!(error.error.message.contains("lookup.ticket")); + + Ok(()) +} + /// Exercises the full dynamic tool call path (server request, client response, model output). #[tokio::test] async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index f80d59d96d..623896626c 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookHandlerType; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HookTrustStatus; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; @@ -26,11 +27,44 @@ use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::skip_if_windows; use pretty_assertions::assert_eq; +use serde::Serialize; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +#[derive(Serialize)] +struct NormalizedHookIdentity { + event_name: &'static str, + #[serde(flatten)] + group: codex_config::MatcherGroup, +} + +fn command_hook_hash( + event_name: &'static str, + matcher: Option<&str>, + command: &str, + timeout_sec: u64, + status_message: Option<&str>, +) -> String { + let identity = NormalizedHookIdentity { + event_name, + group: codex_config::MatcherGroup { + matcher: matcher.map(ToOwned::to_owned), + hooks: vec![codex_config::HookHandlerConfig::Command { + command: command.to_string(), + timeout_sec: Some(timeout_sec), + r#async: false, + status_message: status_message.map(ToOwned::to_owned), + }], + }, + }; + let Ok(value) = codex_config::TomlValue::try_from(identity) else { + unreachable!("normalized hook identity should serialize to TOML"); + }; + codex_config::version_for_toml(&value) +} + fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> { std::fs::write( codex_home.join("config.toml"), @@ -113,6 +147,14 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { display_order: 0, enabled: true, is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "python3 /tmp/listed-hook.py", + /*timeout_sec*/ 5, + Some("running listed hook"), + ), + trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), @@ -183,6 +225,14 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { display_order: 0, enabled: true, is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "echo plugin hook", + /*timeout_sec*/ 7, + Some("running plugin hook"), + ), + trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), @@ -300,6 +350,14 @@ timeout = 5 display_order: 0, enabled: true, is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "echo project hook", + /*timeout_sec*/ 5, + /*status_message*/ None, + ), + trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), @@ -408,6 +466,254 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { Ok(()) } +#[tokio::test] +async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> { + skip_if_windows!(Ok(())); + + let responses = vec![ + create_final_assistant_message_sse_response("Warmup")?, + create_final_assistant_message_sse_response("Untrusted turn")?, + create_final_assistant_message_sse_response("Trusted turn")?, + create_final_assistant_message_sse_response("Modified turn")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py"); + let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl"); + std::fs::write( + &hook_script_path, + format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + hook_log_path = hook_log_path.display(), + ), + )?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[hooks] + +[[hooks.UserPromptSubmit]] + +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "python3 {hook_script_path}" +"#, + server_uri = server.uri(), + hook_script_path = hook_script_path.display(), + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let hook = data[0].hooks[0].clone(); + assert_eq!(hook.trust_status, HookTrustStatus::Untrusted); + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(response)?; + + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert!(!std::fs::exists(&hook_log_path)?); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "trusted_hash": hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let trusted_hook = &data[0].hooks[0]; + assert_eq!(trusted_hook.key, hook.key); + assert_eq!(trusted_hook.current_hash, hook.current_hash); + assert_eq!(trusted_hook.trust_status, HookTrustStatus::Trusted); + + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.UserPromptSubmit".to_string(), + value: serde_json::json!([{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", hook_script_path.display()), + "statusMessage": "modified hook", + }], + }]), + merge_strategy: MergeStrategy::Replace, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let modified_hook = &data[0].hooks[0]; + assert_eq!(modified_hook.key, hook.key); + assert_ne!(modified_hook.current_hash, hook.current_hash); + assert_eq!(modified_hook.trust_status, HookTrustStatus::Modified); + + let third_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "third turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(third_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + Ok(()) +} + #[tokio::test] async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> { skip_if_windows!(Ok(())); @@ -482,6 +788,29 @@ command = "python3 {hook_script_path}" let hook = &data[0].hooks[0]; assert_eq!(hook.enabled, true); + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "trusted_hash": hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), 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 141761e88a..b805f75ba7 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -65,6 +65,9 @@ const TEST_TOOL_NAME: &str = "echo_tool"; const LARGE_RESPONSE_MESSAGE: &str = "large"; const ELICITATION_TRIGGER_MESSAGE: &str = "confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; +const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; +const URL_ELICITATION_URL: &str = "https://github.example/login/device"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { @@ -294,6 +297,109 @@ url = "{mcp_server_url}/mcp" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": URL_ELICITATION_TRIGGER_MESSAGE, + })), + meta: None, + }) + .await?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id, + turn_id: None, + server_name: TEST_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }, + } + ); + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: None, + meta: None, + })?, + ) + .await?; + + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> { let call_id = "call-large-mcp"; @@ -528,6 +634,28 @@ impl ServerHandler for ToolAppsMcpServer { return Ok(CallToolResult::success(vec![Content::text(output)])); } + if message == URL_ELICITATION_TRIGGER_MESSAGE { + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!(result.content, Some(json!({}))); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + let mut result = CallToolResult::structured(json!({ "echoed": message, "threadId": thread_id, diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 830ab0f783..e2039d333a 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::Model; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::ModelServiceTier; use codex_app_server_protocol::ModelUpgradeInfo; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; @@ -51,6 +52,15 @@ fn model_from_preset(preset: &ModelPreset) -> Model { // todo(sayan): fix, maybe make roundtrip use ModelInfo only supports_personality: false, additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset + .service_tiers + .iter() + .map(|service_tier| ModelServiceTier { + id: service_tier.id.clone(), + name: service_tier.name.clone(), + description: service_tier.description.clone(), + }) + .collect(), is_default: preset.is_default, } } 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 86fb78bae1..e82ebe9fe9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -197,7 +197,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ valid_repo_root .path() .join("plugins/valid-plugin/.codex-plugin/plugin.json"), - r#"{"name":"valid-plugin"}"#, + r#"{"name":"valid-plugin","keywords":["api-key","developer tools"]}"#, )?; std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; @@ -246,6 +246,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, + keywords: vec!["api-key".to_string(), "developer tools".to_string()], }], }] ); @@ -548,6 +549,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab screenshots: Vec::new(), screenshot_urls: Vec::new(), }), + keywords: Vec::new(), }, PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), @@ -563,6 +565,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, + keywords: Vec::new(), }, ], }] @@ -1295,6 +1298,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], @@ -1430,6 +1434,13 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and_then(|interface| interface.display_name.as_deref()), Some("Linear") ); + assert_eq!( + remote_marketplace.plugins[0].keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); assert_eq!(response.featured_plugin_ids, Vec::::new()); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index fd082ab412..bb42714d3d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -172,6 +172,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], @@ -281,6 +282,13 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> response.plugin.description.as_deref(), Some("Track work in Linear") ); + assert_eq!( + response.plugin.summary.keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!(response.plugin.skills[0].name, "plan-work"); assert_eq!(response.plugin.skills[0].path, None); @@ -580,6 +588,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> r##"{ "name": "demo-plugin", "description": "Longer manifest description", + "keywords": ["api-key", "developer tools"], "interface": { "displayName": "Plugin Display Name", "shortDescription": "Short description for subtitle", @@ -740,6 +749,10 @@ enabled = true "Find my next action".to_string() ]) ); + assert_eq!( + response.plugin.summary.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!( response.plugin.skills[0].name, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index a44a64be7c..8175ecd459 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; @@ -14,7 +15,10 @@ use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginShareDeleteResponse; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; @@ -164,6 +168,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: Some(expected_plugin_path), @@ -173,6 +178,154 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_save_forwards_access_policy() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + "discoverability": "PRIVATE", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "workspace-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": expected_plugin_path, + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + { + "principalType": "workspace", + "principalId": "workspace-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareSaveResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareSaveResponse { + remote_plugin_id: "plugins_123".to_string(), + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "remotePluginId": "plugins_123", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets" + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { let codex_home = TempDir::new()?; @@ -239,6 +392,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, @@ -248,6 +402,82 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("PUT")) + .and(path("/backend-api/public/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareUpdateTargetsResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + } + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { let codex_home = TempDir::new()?; @@ -342,6 +572,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index b04eb12a45..9363217864 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -42,6 +42,7 @@ use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; +use codex_state::StateRuntime; use codex_thread_store::InMemoryThreadStore; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -67,6 +68,13 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste .loader_overrides(loader_overrides.clone()) .build() .await?; + let sqlite_home = TempDir::new()?; + let state_db = StateRuntime::init( + sqlite_home.path().to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .expect("remote thread store regression test should initialize state db"); let thread_store = InMemoryThreadStore::for_id(store_id.clone()); let _in_memory_store = InMemoryThreadStoreId { store_id }; @@ -80,7 +88,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, - state_db: None, + state_db: Some(state_db), environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index d56b9318e3..bf0271f821 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; @@ -85,6 +86,17 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() assert_eq!(review_thread_id, thread_id.clone()); let turn_id = turn.id.clone(); assert_eq!(turn.status, TurnStatus::InProgress); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + 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. let mut saw_entered_review_mode = false; @@ -182,6 +194,17 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> .await??; let ReviewStartResponse { turn, .. } = to_response::(review_resp)?; let turn_id = turn.id.clone(); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + content: vec![V2UserInput::Text { + text: "commit 1234567: Check review approvals".to_string(), + text_elements: Vec::new(), + }], + }] + ); let server_req = timeout( DEFAULT_READ_TIMEOUT, @@ -300,6 +323,17 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( } = to_response::(review_resp)?; assert_eq!(turn.status, TurnStatus::InProgress); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn.id.clone(), + content: vec![V2UserInput::Text { + text: "detached review".to_string(), + text_elements: Vec::new(), + }], + }] + ); assert_ne!( review_thread_id, thread_id, "detached review should run on a different thread" @@ -329,6 +363,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( let started: ThreadStartedNotification = serde_json::from_value(notification.params.expect("params must be present"))?; assert_eq!(started.thread.id, review_thread_id); + assert_eq!(started.thread.session_id, review_thread_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index b95adb9044..6b98e39b53 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -675,6 +675,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( personality: None, ephemeral: None, session_start_source: None, + thread_source: None, dynamic_tools: None, environments: None, mock_experimental_field: None, 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 fd773f2e30..8394f9839e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -90,6 +91,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id.clone(), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -106,11 +108,21 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { .get("thread") .and_then(Value::as_object) .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "forked threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "forked threads do not inherit a name; expected `name: null`" ); + assert_eq!( + fork_result.get("sessionId"), + None, + "thread/fork should not serialize a top-level `sessionId`" + ); let after_contents = std::fs::read_to_string(&original_path)?; assert_eq!( @@ -119,6 +131,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); assert_ne!(thread.id, conversation_id); + assert_eq!(thread.session_id, thread.id); assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); @@ -128,6 +141,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { assert_ne!(thread_path.as_path(), original_path); assert!(thread.cwd.as_path().is_absolute()); assert_eq!(thread.source, SessionSource::VsCode); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); assert_eq!(thread.name, None); assert_eq!( @@ -188,6 +202,13 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Some(&json!([])), "thread/started must not emit copied fork turns" ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied fork origin" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; let mut expected_started_thread = thread; @@ -299,6 +320,7 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id, + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -403,6 +425,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id, + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -415,7 +438,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked"); + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user"); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index ebaba81852..80254d8f47 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -879,6 +879,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() assert_eq!(ids, vec![subagent_id.as_str()]); assert_ne!(cli_id, subagent_id); assert!(matches!(data[0].source, SessionSource::SubAgent(_))); + assert_eq!(data[0].session_id, subagent_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index d06b22edc4..c78e9b8152 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -33,6 +33,7 @@ use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() -> Result<()> { @@ -76,6 +77,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() to_response::(update_resp)?; assert_eq!(updated.id, thread.id); + assert_eq!(updated.session_id, thread.session_id); assert_eq!( updated.git_info, Some(GitInfo { @@ -89,6 +91,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() .get("thread") .and_then(Value::as_object) .expect("thread/metadata/update result.thread must be an object"); + assert_eq!( + updated_thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); let updated_git_info_json = updated_thread_json .get("gitInfo") .and_then(Value::as_object) @@ -170,6 +176,57 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/ephemeral".to_string())), + origin_url: None, + }), + }) + .await?; + let update_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(update_id)), + ) + .await??; + + assert_eq!(update_err.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + update_err.error.message, + format!( + "ephemeral thread does not support metadata updates: {}", + thread.id + ) + ); + + Ok(()) +} + #[tokio::test] async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs index 6626b7a6cc..951e4d74e9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs @@ -22,11 +22,7 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; use codex_core::find_thread_name_by_id; -use codex_core::find_thread_path_by_id_str; use codex_protocol::ThreadId; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -85,10 +81,6 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { read_notification_for_method(&mut ws2, "thread/name/updated").await?; assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; - assert_eq!( - thread_name_update_rollout_count(codex_home.path(), &conversation_id).await?, - 1 - ); assert_no_message(&mut ws1, Duration::from_millis(250)).await?; assert_no_message(&mut ws2, Duration::from_millis(250)).await?; @@ -141,10 +133,6 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { read_notification_for_method(&mut ws2, "thread/name/updated").await?; assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; - assert_eq!( - thread_name_update_rollout_count(codex_home.path(), &conversation_id).await?, - 1 - ); assert_no_message(&mut ws1, Duration::from_millis(250)).await?; assert_no_message(&mut ws2, Duration::from_millis(250)).await?; @@ -206,24 +194,3 @@ async fn assert_legacy_thread_name( ); Ok(()) } - -async fn thread_name_update_rollout_count( - codex_home: &Path, - conversation_id: &str, -) -> Result { - let rollout_path = - find_thread_path_by_id_str(codex_home, conversation_id, /*state_db_ctx*/ None) - .await? - .context("rollout path")?; - let contents = tokio::fs::read_to_string(rollout_path).await?; - Ok(contents - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|line| { - matches!( - line.item, - RolloutItem::EventMsg(EventMsg::ThreadNameUpdated(_)) - ) - }) - .count()) -} 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 8c46a5ad95..0e9a7a9a05 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -33,6 +33,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -174,6 +175,7 @@ async fn thread_read_can_include_turns() -> Result<()> { assert_eq!(thread.turns.len(), 1); let turn = &thread.turns[0]; assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.items_view, TurnItemsView::Full); assert_eq!(turn.items.len(), 1, "expected user message item"); match &turn.items[0] { ThreadItem::UserMessage { content, .. } => { @@ -234,6 +236,10 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { backwards_cursor, } = to_response::(read_resp)?; assert_eq!(turn_user_texts(&data), vec!["third", "second"]); + assert!( + data.iter() + .all(|turn| turn.items_view == TurnItemsView::Full) + ); let next_cursor = next_cursor.expect("expected nextCursor for older turns"); let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); @@ -1113,6 +1119,7 @@ async fn seed_pathless_store_thread( thread_id, forked_from_id: None, source: ProtocolSessionSource::Cli, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { 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 55c0d96eb5..7d7de25515 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -38,9 +38,11 @@ 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::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -239,15 +241,20 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let conversation_id = create_fake_rollout_with_text_elements( + let conversation_id = create_fake_rollout( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved user message", - Vec::new(), Some("mock_provider"), /*git_info*/ None, )?; + set_thread_source_on_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + &conversation_id, + "user", + )?; let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -264,10 +271,35 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { ) .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed"); + assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user"); + assert_eq!(event["event_params"]["thread_source"], "user"); + Ok(()) +} + +fn set_thread_source_on_fake_rollout( + codex_home: &std::path::Path, + filename_ts: &str, + thread_id: &str, + thread_source: &str, +) -> Result<()> { + let path = rollout_path(codex_home, filename_ts, thread_id); + let contents = std::fs::read_to_string(&path)?; + let mut lines = contents.lines(); + let session_meta = lines + .next() + .ok_or_else(|| anyhow::anyhow!("fake rollout missing session meta"))?; + let mut session_meta: serde_json::Value = serde_json::from_str(session_meta)?; + session_meta["payload"]["thread_source"] = serde_json::json!(thread_source); + let remaining = lines.collect::>().join("\n"); + std::fs::write(&path, format!("{session_meta}\n{remaining}\n"))?; Ok(()) } @@ -1178,6 +1210,7 @@ stream_max_retries = 0 originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -1629,6 +1662,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { .await??; let TurnStartResponse { turn: running_turn } = to_response::(running_turn_resp)?; + assert_eq!(running_turn.items_view, TurnItemsView::NotLoaded); timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_notification_message("turn/started"), diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 3487b9e36a..5f79db0e26 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -119,11 +119,16 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() .and_then(Value::as_object) .expect("thread/rollback result.thread must be an object"); assert_eq!(rolled_back_thread.name, None); + assert_eq!(rolled_back_thread.session_id, thread.session_id); assert_eq!( thread_json.get("name"), Some(&Value::Null), "thread/rollback must serialize `name: null` when unset" ); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); assert_eq!(rolled_back_thread.turns.len(), 1); assert_eq!(rolled_back_thread.status, ThreadStatus::Idle); diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index d8a50b88a4..78155d8c9a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::DeprecationNoticeNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; @@ -14,6 +15,7 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -26,7 +28,6 @@ use codex_core::config::set_project_trust_level; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -50,6 +51,46 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +#[tokio::test] +async fn thread_start_deprecates_persist_extended_history_true() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + persist_extended_history: true, + ..Default::default() + }) + .await?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("deprecationNotice"), + ) + .await??; + let notice: DeprecationNoticeNotification = serde_json::from_value( + notification + .params + .expect("deprecationNotice params should be present"), + )?; + assert_eq!( + notice.summary, + "persistExtendedHistory is deprecated and ignored" + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_start_creates_thread_and_emits_started() -> Result<()> { // Provide a mock server and config so model wiring is valid. @@ -66,6 +107,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { let req_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("gpt-5.2".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -82,6 +124,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { model_provider, .. } = to_response::(resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); assert!(!thread.id.is_empty(), "thread id should not be empty"); assert!( thread.preview.is_empty(), @@ -97,6 +143,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { "new persistent threads should not be ephemeral" ); assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); let thread_path = thread.path.clone().expect("thread path should be present"); assert!(thread_path.is_absolute(), "thread path should be absolute"); assert!( @@ -109,16 +156,31 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .get("thread") .and_then(Value::as_object) .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "new threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "new threads should serialize `name: null`" ); + assert_eq!( + resp_result.get("sessionId"), + None, + "thread/start should not serialize a top-level `sessionId`" + ); assert_eq!( thread_json.get("ephemeral").and_then(Value::as_bool), Some(false), "new persistent threads should serialize `ephemeral: false`" ); + assert_eq!( + thread_json.get("threadSource").and_then(Value::as_str), + Some("user"), + "new threads should serialize the caller-supplied thread origin" + ); assert_eq!(thread.name, None); // A corresponding thread/started notification should arrive. @@ -160,6 +222,13 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Some(false), "thread/started should serialize `ephemeral: false` for new persistent threads" ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied thread origin" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); @@ -271,7 +340,10 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::User), + ..Default::default() + }) .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -283,7 +355,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; assert_eq!(payload["events"].as_array().expect("events array").len(), 1); let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new"); + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user"); Ok(()) } @@ -329,7 +401,7 @@ model_reasoning_effort = "high" } #[tokio::test] -async fn thread_start_accepts_flex_service_tier() -> Result<()> { +async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; @@ -338,9 +410,10 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let service_tier_id = "experimental-tier-id".to_string(); let req_id = mcp .send_thread_start_request(ThreadStartParams { - service_tier: Some(Some(ServiceTier::Flex)), + service_tier: Some(Some(service_tier_id.clone())), ..Default::default() }) .await?; @@ -352,7 +425,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { .await??; let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; - assert_eq!(service_tier, Some(ServiceTier::Flex)); + assert_eq!(service_tier, Some(service_tier_id)); Ok(()) } 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 7636cbfa61..bfe22f6c55 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -40,10 +40,12 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::TextElement; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; @@ -115,6 +117,7 @@ async fn turn_start_sends_originator_header() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -182,6 +185,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -354,6 +358,71 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( Ok(()) } +#[tokio::test] +async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let service_tier_id = "experimental-tier-id".to_string(); + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + service_tier: Some(Some(service_tier_id.clone())), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!( + response_mock.single_request().body_json()["service_tier"], + json!(service_tier_id) + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { let server = responses::start_mock_server().await; @@ -463,6 +532,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -869,6 +939,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( codex_app_server_protocol::TurnStatus::InProgress ); assert_eq!(started.turn.id, turn.id); + assert_eq!(started.turn.items_view, TurnItemsView::NotLoaded); + assert!(started.turn.items.is_empty()); let completed_notif: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, @@ -883,6 +955,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( assert_eq!(completed.thread_id, thread.id); assert_eq!(completed.turn.id, turn.id); assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(completed.turn.items_view, TurnItemsView::NotLoaded); + assert!(completed.turn.items.is_empty()); // Send a second turn that exercises the overrides path: change the model. let turn_req2 = mcp @@ -916,6 +990,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( assert_eq!(started2.thread_id, thread.id); assert_eq!(started2.turn.id, turn2.id); assert_eq!(started2.turn.status, TurnStatus::InProgress); + assert_eq!(started2.turn.items_view, TurnItemsView::NotLoaded); + assert!(started2.turn.items.is_empty()); let completed_notif2: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, @@ -930,6 +1006,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( assert_eq!(completed2.thread_id, thread.id); assert_eq!(completed2.turn.id, turn2.id); assert_eq!(completed2.turn.status, TurnStatus::Completed); + assert_eq!(completed2.turn.items_view, TurnItemsView::NotLoaded); + assert!(completed2.turn.items.is_empty()); Ok(()) } diff --git a/codex-rs/builtin-mcps/BUILD.bazel b/codex-rs/builtin-mcps/BUILD.bazel new file mode 100644 index 0000000000..9c738d636b --- /dev/null +++ b/codex-rs/builtin-mcps/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "builtin-mcps", + crate_name = "codex_builtin_mcps", +) diff --git a/codex-rs/builtin-mcps/Cargo.toml b/codex-rs/builtin-mcps/Cargo.toml new file mode 100644 index 0000000000..de64ec7033 --- /dev/null +++ b/codex-rs/builtin-mcps/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-builtin-mcps" +version.workspace = true + +[lib] +name = "codex_builtin_mcps" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-config = { workspace = true } +codex-memories-mcp = { workspace = true } +codex-utils-absolute-path = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/builtin-mcps/src/lib.rs b/codex-rs/builtin-mcps/src/lib.rs new file mode 100644 index 0000000000..abecc994d6 --- /dev/null +++ b/codex-rs/builtin-mcps/src/lib.rs @@ -0,0 +1,132 @@ +//! Built-in MCP servers shipped with Codex. +//! +//! Built-ins use the same stdio MCP path as user-configured servers, but are +//! declared here so product-owned MCPs do not need to live in `codex-core`. + +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; + +pub const MEMORIES_MCP_SERVER_NAME: &str = "memories"; +const BUILTIN_MCP_SUBCOMMAND: &str = "builtin-mcp"; + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinMcpServerOptions<'a> { + pub codex_self_exe: Option<&'a Path>, + pub codex_home: &'a Path, + pub memories_enabled: bool, +} + +pub fn configured_builtin_mcp_servers( + options: BuiltinMcpServerOptions<'_>, +) -> HashMap { + let Some(codex_self_exe) = options.codex_self_exe else { + return HashMap::new(); + }; + + let mut servers = HashMap::new(); + if options.memories_enabled { + servers.insert( + MEMORIES_MCP_SERVER_NAME.to_string(), + builtin_stdio_server_config( + codex_self_exe, + options.codex_home, + MEMORIES_MCP_SERVER_NAME, + ), + ); + } + servers +} + +pub async fn run_builtin_mcp_server( + name: &str, + codex_home: &AbsolutePathBuf, +) -> anyhow::Result<()> { + match name { + MEMORIES_MCP_SERVER_NAME => codex_memories_mcp::run_stdio_server(codex_home).await, + _ => anyhow::bail!("unknown built-in MCP server: {name}"), + } +} + +fn builtin_stdio_server_config( + codex_self_exe: &Path, + codex_home: &Path, + name: &str, +) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: codex_self_exe.to_string_lossy().into_owned(), + args: vec![ + BUILTIN_MCP_SUBCOMMAND.to_string(), + name.to_string(), + "--codex-home".to_string(), + codex_home.to_string_lossy().into_owned(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: true, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn configured_builtin_mcp_servers_adds_memories_when_enabled() { + let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home"); + let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: Some(Path::new("/tmp/codex")), + codex_home: codex_home.as_path(), + memories_enabled: true, + }); + + let server = servers + .get(MEMORIES_MCP_SERVER_NAME) + .expect("memories server should exist"); + assert_eq!( + server.transport, + McpServerTransportConfig::Stdio { + command: "/tmp/codex".to_string(), + args: vec![ + "builtin-mcp".to_string(), + "memories".to_string(), + "--codex-home".to_string(), + codex_home.as_path().to_string_lossy().into_owned(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + } + + #[test] + fn configured_builtin_mcp_servers_requires_reexec_path() { + let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home"); + let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: None, + codex_home: codex_home.as_path(), + memories_enabled: true, + }); + + assert!(servers.is_empty()); + } +} diff --git a/codex-rs/bwrap/BUILD.bazel b/codex-rs/bwrap/BUILD.bazel new file mode 100644 index 0000000000..3d0b89b966 --- /dev/null +++ b/codex-rs/bwrap/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "bwrap", + crate_name = "codex_bwrap", + # Bazel wires vendored bubblewrap + libcap via :bwrap-ffi below and sets + # bwrap_available explicitly, so we skip Cargo's build.rs in Bazel builds. + build_script_enabled = False, + deps_extra = select({ + "@platforms//os:linux": [":bwrap-ffi"], + "//conditions:default": [], + }), + rustc_flags_extra = select({ + "@platforms//os:linux": ["--cfg=bwrap_available"], + "//conditions:default": [], + }), +) + +cc_library( + name = "bwrap-ffi", + srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], + hdrs = [ + "config.h", + "//codex-rs/vendor:bubblewrap_headers", + ], + copts = [ + "-D_GNU_SOURCE", + "-Dmain=bwrap_main", + ], + includes = ["."], + deps = ["@libcap//:libcap"], + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], +) diff --git a/codex-rs/bwrap/Cargo.toml b/codex-rs/bwrap/Cargo.toml new file mode 100644 index 0000000000..ed7010c8fd --- /dev/null +++ b/codex-rs/bwrap/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-bwrap" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "bwrap" +path = "src/main.rs" + +[lints] +workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/codex-rs/bwrap/build.rs b/codex-rs/bwrap/build.rs new file mode 100644 index 0000000000..d9d87932b2 --- /dev/null +++ b/codex-rs/bwrap/build.rs @@ -0,0 +1,106 @@ +use std::env; +use std::path::Path; +use std::path::PathBuf; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(bwrap_available)"); + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); + println!("cargo:rerun-if-env-changed=CODEX_SKIP_BWRAP_BUILD"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + for source in ["bubblewrap.c", "bind-mount.c", "network.c", "utils.c"] { + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join(source).display() + ); + } + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "linux" || env::var_os("CODEX_SKIP_BWRAP_BUILD").is_some() { + return; + } + + if let Err(err) = try_build_bwrap() { + panic!("failed to compile bubblewrap for Linux target: {err}"); + } +} + +fn try_build_bwrap() -> Result<(), String> { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); + let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; + let libcap = pkg_config::Config::new() + .cargo_metadata(false) + .probe("libcap") + .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; + + let config_h = out_dir.join("config.h"); + std::fs::write( + &config_h, + r#"#pragma once +#define PACKAGE_STRING "bubblewrap built for Codex" +"#, + ) + .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; + + let mut build = cc::Build::new(); + build + .file(src_dir.join("bubblewrap.c")) + .file(src_dir.join("bind-mount.c")) + .file(src_dir.join("network.c")) + .file(src_dir.join("utils.c")) + .include(&out_dir) + .include(&src_dir) + .define("_GNU_SOURCE", None) + // Rename `main` so the Rust wrapper can expose the Cargo-built binary. + .define("main", Some("bwrap_main")); + for include_path in libcap.include_paths { + // Use -idirafter so target sysroot headers win (musl cross builds), + // while still allowing libcap headers from the host toolchain. + build.flag(format!("-idirafter{}", include_path.display())); + } + + build.compile("standalone_bwrap"); + for link_path in libcap.link_paths { + println!("cargo:rustc-link-search=native={}", link_path.display()); + } + for lib in libcap.libs { + println!("cargo:rustc-link-lib={lib}"); + } + println!("cargo:rustc-cfg=bwrap_available"); + Ok(()) +} + +/// Resolve the bubblewrap source directory used for build-time compilation. +/// +/// Priority: +/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. +/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. +fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { + if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { + let src_dir = PathBuf::from(path); + if src_dir.exists() { + return Ok(src_dir); + } + return Err(format!( + "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", + src_dir.display() + )); + } + + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + if vendor_dir.exists() { + return Ok(vendor_dir); + } + + Err(format!( + "expected vendored bubblewrap at {}, but it was not found.\n\ +Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", + vendor_dir.display() + )) +} diff --git a/codex-rs/bwrap/config.h b/codex-rs/bwrap/config.h new file mode 100644 index 0000000000..f73932a0f8 --- /dev/null +++ b/codex-rs/bwrap/config.h @@ -0,0 +1 @@ +#define PACKAGE_STRING "bubblewrap built for Codex" diff --git a/codex-rs/bwrap/src/main.rs b/codex-rs/bwrap/src/main.rs new file mode 100644 index 0000000000..09c624aa9e --- /dev/null +++ b/codex-rs/bwrap/src/main.rs @@ -0,0 +1,45 @@ +#[cfg(all(target_os = "linux", bwrap_available))] +fn main() { + use std::ffi::CStr; + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + + unsafe extern "C" { + fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; + } + + let cstrings = std::env::args_os() + .map(|arg| { + CString::new(arg.as_os_str().as_bytes()) + .unwrap_or_else(|err| panic!("failed to convert argv to CString: {err}")) + }) + .collect::>(); + let mut argv_ptrs = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect::>(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: We provide a null-terminated argv vector whose pointers remain + // valid for the duration of the call. + let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) }; + std::process::exit(exit_code); +} + +#[cfg(all(target_os = "linux", not(bwrap_available)))] +fn main() { + panic!( + r#"bubblewrap is not available in this build. +Notes: +- ensure the target OS is Linux +- libcap headers must be available via pkg-config +- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# + ); +} + +#[cfg(not(target_os = "linux"))] +fn main() { + panic!("bwrap is only supported on Linux"); +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index cdee241b42..b644734f4c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -24,6 +24,7 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-builtin-mcps = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 6750cbf39e..5bea8ce78d 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -10,10 +10,10 @@ use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; pub use debug_sandbox::run_command_under_windows; -pub use login::read_agent_identity_from_stdin; +pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; -pub use login::run_login_with_agent_identity; +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_device_code; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 1baa344b8d..16add7ac90 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -13,7 +13,7 @@ use codex_core::config::Config; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; -use codex_login::login_with_agent_identity; +use codex_login::login_with_access_token; use codex_login::login_with_api_key; use codex_login::logout_with_revoke; use codex_login::run_device_code_login; @@ -35,8 +35,8 @@ const CHATGPT_LOGIN_DISABLED_MESSAGE: &str = "ChatGPT login is disabled. Use API key login instead."; const API_KEY_LOGIN_DISABLED_MESSAGE: &str = "API key login is disabled. Use ChatGPT login instead."; -const AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE: &str = - "Agent Identity login is disabled. Use API key login instead."; +const ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE: &str = + "Access token login is disabled. Use API key login instead."; const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in"; /// Installs a small file-backed tracing layer for direct `codex login` flows. @@ -190,22 +190,22 @@ pub async fn run_login_with_api_key( } } -pub async fn run_login_with_agent_identity( +pub async fn run_login_with_access_token( cli_config_overrides: CliConfigOverrides, - agent_identity: String, + access_token: String, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); - tracing::info!("starting agent identity login flow"); + tracing::info!("starting access token login flow"); if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - eprintln!("{AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE}"); + eprintln!("{ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } - match login_with_agent_identity( + match login_with_access_token( &config.codex_home, - &agent_identity, + &access_token, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), ) @@ -216,7 +216,7 @@ pub async fn run_login_with_agent_identity( std::process::exit(0); } Err(e) => { - eprintln!("Error logging in with Agent Identity: {e}"); + eprintln!("Error logging in with access token: {e}"); std::process::exit(1); } } @@ -230,11 +230,11 @@ pub fn read_api_key_from_stdin() -> String { ) } -pub fn read_agent_identity_from_stdin() -> String { +pub fn read_access_token_from_stdin() -> String { read_stdin_secret( - "--with-agent-identity expects the Agent Identity token on stdin. Try piping it, e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`.", - "Reading Agent Identity token from stdin...", - "No Agent Identity token provided via stdin.", + "--with-access-token expects the access token on stdin. Try piping it, e.g. `printenv CODEX_ACCESS_TOKEN | codex login --with-access-token`.", + "Reading access token from stdin...", + "No access token provided via stdin.", ) } @@ -388,7 +388,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { std::process::exit(0); } AuthMode::AgentIdentity => { - eprintln!("Logged in using Agent Identity"); + eprintln!("Logged in using access token"); std::process::exit(0); } }, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c56055ea2d..f3545629c1 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,10 +10,10 @@ use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::WindowsCommand; -use codex_cli::read_agent_identity_from_stdin; +use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; -use codex_cli::run_login_with_agent_identity; +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_device_code; @@ -123,6 +123,10 @@ enum Subcommand { /// Start Codex as an MCP server (stdio). McpServer, + /// Internal: start a Codex-shipped MCP server (stdio). + #[clap(hide = true, name = "builtin-mcp")] + BuiltinMcp(BuiltinMcpCommand), + /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), @@ -175,6 +179,13 @@ enum Subcommand { Features(FeaturesCli), } +#[derive(Debug, Args)] +struct BuiltinMcpCommand { + name: String, + #[arg(long)] + codex_home: PathBuf, +} + #[derive(Debug, Parser)] #[command(bin_name = "codex plugin")] struct PluginCli { @@ -364,10 +375,10 @@ struct LoginCommand { with_api_key: bool, #[arg( - long = "with-agent-identity", - help = "Read the experimental Agent Identity token from stdin (e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`)" + long = "with-access-token", + help = "Read the access token from stdin (e.g. `printenv CODEX_ACCESS_TOKEN | codex login --with-access-token`)" )] - with_agent_identity: bool, + with_access_token: bool, #[arg( long = "api-key", @@ -447,12 +458,20 @@ struct AppServerCommand { #[derive(Debug, Parser)] struct ExecServerCommand { /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`. - #[arg( - long = "listen", - value_name = "URL", - default_value = "ws://127.0.0.1:0" - )] - listen: String, + #[arg(long = "listen", value_name = "URL", conflicts_with = "remote")] + listen: Option, + + /// Register this exec-server as a remote executor using the given base URL. + #[arg(long = "remote", value_name = "URL", requires = "executor_id")] + remote: Option, + + /// Executor id to attach to when registering remotely. + #[arg(long = "executor-id", value_name = "ID")] + executor_id: Option, + + /// Human-readable executor name. + #[arg(long = "name", value_name = "NAME")] + name: Option, } #[derive(Debug, clap::Subcommand)] @@ -801,6 +820,15 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } + Some(Subcommand::BuiltinMcp(command)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "builtin-mcp", + )?; + let codex_home = AbsolutePathBuf::try_from(command.codex_home)?; + codex_builtin_mcps::run_builtin_mcp_server(&command.name, &codex_home).await?; + } Some(Subcommand::Mcp(mut mcp_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -966,9 +994,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_login_status(login_cli.config_overrides).await; } None => { - if login_cli.with_api_key && login_cli.with_agent_identity { + if login_cli.with_api_key && login_cli.with_access_token { eprintln!( - "Choose one login credential source: --with-api-key or --with-agent-identity." + "Choose one login credential source: --with-api-key or --with-access-token." ); std::process::exit(1); } else if login_cli.use_device_code { @@ -986,10 +1014,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; - } else if login_cli.with_agent_identity { - let agent_identity = read_agent_identity_from_stdin(); - run_login_with_agent_identity(login_cli.config_overrides, agent_identity) - .await; + } else if login_cli.with_access_token { + 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; } @@ -1265,7 +1292,23 @@ async fn run_exec_server_command( codex_self_exe, arg0_paths.codex_linux_sandbox_exe.clone(), )?; - codex_exec_server::run_main(&cmd.listen, runtime_paths) + if let Some(base_url) = cmd.remote { + let executor_id = cmd + .executor_id + .ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?; + let mut remote_config = + codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?; + if let Some(name) = cmd.name { + remote_config.name = name; + } + codex_exec_server::run_remote_executor(remote_config, runtime_paths).await?; + return Ok(()); + } + let listen_url = cmd + .listen + .as_deref() + .unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL); + codex_exec_server::run_main(listen_url, runtime_paths) .await .map_err(anyhow::Error::from_boxed) } @@ -1388,7 +1431,7 @@ async fn run_debug_prompt_input_command( }); } - let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?; + let prompt_input = codex_core::build_prompt_input(config, input).await?; println!("{}", serde_json::to_string_pretty(&prompt_input)?); Ok(()) diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index 7fd9f7af27..e290d05939 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -51,16 +51,16 @@ fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { } #[test] -fn login_with_agent_identity_rejects_invalid_jwt() -> Result<()> { +fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { let codex_home = TempDir::new()?; write_file_auth_config(codex_home.path())?; let mut cmd = codex_command(codex_home.path())?; - cmd.args(["login", "--with-agent-identity"]) + cmd.args(["login", "--with-access-token"]) .write_stdin("not-a-jwt\n") .assert() .failure() - .stderr(contains("Error logging in with Agent Identity")); + .stderr(contains("Error logging in with access token")); Ok(()) } diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index 23b2ce2306..19d6c3ab45 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -9,6 +9,9 @@ doctest = false name = "codex_code_mode" path = "src/lib.rs" +[features] +sandbox = ["v8/v8_enable_sandbox"] + [lints] workspace = true diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index e2d2ed3c3c..91b251c41f 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -32,6 +32,10 @@ pub struct CompactionInput<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, } @@ -235,6 +239,11 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } +#[derive(Debug, Serialize)] +pub struct ResponseProcessedWsRequest { + pub response_id: String, +} + pub fn response_create_client_metadata( client_metadata: Option>, trace: Option<&W3cTraceContext>, @@ -263,6 +272,8 @@ pub fn response_create_client_metadata( pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), + #[serde(rename = "response.processed")] + ResponseProcessed(ResponseProcessedWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 17b478d1fd..cc1be2846a 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -6,7 +6,7 @@ use crate::error::ApiError; use crate::provider::Provider; use crate::requests::Compression; use crate::requests::attach_item_ids; -use crate::requests::headers::build_conversation_headers; +use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; use crate::sse::spawn_response_stream; @@ -30,7 +30,8 @@ pub struct ResponsesClient { #[derive(Default)] pub struct ResponsesOptions { - pub conversation_id: Option, + pub session_id: Option, + pub thread_id: Option, pub session_source: Option, pub extra_headers: HeaderMap, pub compression: Compression, @@ -72,7 +73,8 @@ impl ResponsesClient { options: ResponsesOptions, ) -> Result { let ResponsesOptions { - conversation_id, + session_id, + thread_id, session_source, extra_headers, compression, @@ -86,10 +88,10 @@ impl ResponsesClient { } let mut headers = extra_headers; - if let Some(ref conv_id) = conversation_id { - insert_header(&mut headers, "x-client-request-id", conv_id); + if let Some(ref thread_id) = thread_id { + insert_header(&mut headers, "x-client-request-id", thread_id); } - headers.extend(build_conversation_headers(conversation_id)); + headers.extend(build_session_headers(session_id, thread_id)); if let Some(subagent) = subagent_header(&session_source) { insert_header(&mut headers, "x-openai-subagent", &subagent); } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index dd947a9dd7..c5a682b328 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,5 +1,6 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; +use crate::common::ResponseProcessedWsRequest; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; use crate::error::ApiError; @@ -204,6 +205,40 @@ impl ResponsesWebsocketConnection { self.stream.lock().await.is_none() } + #[instrument( + name = "responses_websocket.send_response_processed", + level = "info", + skip_all, + fields(transport = "responses_websocket", api.path = "responses") + )] + #[expect( + clippy::await_holding_invalid_type, + reason = "the guard serializes exclusive use of the websocket while sending a request frame" + )] + pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { + let request = + ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); + let request_body = serde_json::to_value(&request).map_err(|err| { + ApiError::Stream(format!("failed to encode websocket request: {err}")) + })?; + + let mut guard = self.stream.lock().await; + let Some(ws_stream) = guard.as_mut() else { + return Err(ApiError::Stream( + "websocket connection is closed".to_string(), + )); + }; + + send_websocket_request( + ws_stream, + request_body, + self.idle_timeout, + self.telemetry.as_ref(), + /*connection_reused*/ true, + ) + .await + } + #[instrument( name = "responses_websocket.stream_request", level = "info", @@ -545,36 +580,14 @@ async fn run_websocket_response_stream( connection_reused: bool, ) -> Result<(), ApiError> { let mut last_server_model: Option = None; - let request_text = match serde_json::to_string(&request_body) { - Ok(text) => text, - Err(err) => { - return Err(ApiError::Stream(format!( - "failed to encode websocket request: {err}" - ))); - } - }; - trace!("websocket request: {request_text}"); - - let request_start = Instant::now(); - let result = tokio::time::timeout( + send_websocket_request( + ws_stream, + request_body, idle_timeout, - ws_stream.send(Message::Text(request_text.into())), + telemetry.as_ref(), + connection_reused, ) - .await - .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) - .and_then(|result| { - result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) - }); - - if let Some(t) = telemetry.as_ref() { - t.on_ws_request( - request_start.elapsed(), - result.as_ref().err(), - connection_reused, - ); - } - - result?; + .await?; loop { let poll_start = Instant::now(); @@ -671,6 +684,47 @@ async fn run_websocket_response_stream( Ok(()) } +async fn send_websocket_request( + ws_stream: &WsStream, + request_body: Value, + idle_timeout: Duration, + telemetry: Option<&Arc>, + connection_reused: bool, +) -> Result<(), ApiError> { + let request_text = match serde_json::to_string(&request_body) { + Ok(text) => text, + Err(err) => { + return Err(ApiError::Stream(format!( + "failed to encode websocket request: {err}" + ))); + } + }; + trace!("websocket request: {request_text}"); + + let request_start = Instant::now(); + let result = tokio::time::timeout( + idle_timeout, + ws_stream.send(Message::Text(request_text.into())), + ) + .await + .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) + .and_then(|result| { + result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) + }); + + if let Some(t) = telemetry.as_ref() { + t.on_ws_request( + request_start.elapsed(), + result.as_ref().err(), + connection_reused, + ); + } + + result?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 0b8aee266b..e6f097db38 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -10,7 +10,7 @@ pub(crate) mod requests; pub(crate) mod sse; pub(crate) mod telemetry; -pub use crate::requests::headers::build_conversation_headers; +pub use crate::requests::headers::build_session_headers; pub use codex_client::RequestTelemetry; pub use codex_client::ReqwestTransport; pub use codex_client::TransportError; @@ -30,6 +30,7 @@ pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; +pub use crate::common::ResponseProcessedWsRequest; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index d1ab834109..d91d2a2bf1 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -2,11 +2,14 @@ use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderValue; -pub fn build_conversation_headers(conversation_id: Option) -> HeaderMap { +pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); - if let Some(id) = conversation_id { + if let Some(id) = session_id { insert_header(&mut headers, "session_id", &id); } + if let Some(id) = thread_id { + insert_header(&mut headers, "thread_id", &id); + } headers } diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 218a99f9b2..a2a29ba16d 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -444,7 +444,8 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { .stream_request( request, ResponsesOptions { - conversation_id: Some("sess_123".into()), + session_id: Some("sess_123".into()), + thread_id: Some("thread_123".into()), session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)), extra_headers, compression: Compression::None, @@ -461,6 +462,16 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { req.headers.get("session_id").and_then(|v| v.to_str().ok()), Some("sess_123") ); + assert_eq!( + req.headers.get("thread_id").and_then(|v| v.to_str().ok()), + Some("thread_123") + ); + assert_eq!( + req.headers + .get("x-client-request-id") + .and_then(|v| v.to_str().ok()), + Some("thread_123") + ); assert_eq!( req.headers .get("x-openai-subagent") diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9f95c9441f..d2b31180b9 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -75,6 +75,7 @@ async fn models_client_hits_models_endpoint() { supported_in_api: true, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index c3061adca9..c47bd947e6 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } async-channel = { workspace = true } codex-async-utils = { workspace = true } codex-api = { workspace = true } +codex-builtin-mcps = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/codex-mcp/src/auth_elicitation.rs b/codex-rs/codex-mcp/src/auth_elicitation.rs new file mode 100644 index 0000000000..77c7b78c55 --- /dev/null +++ b/codex-rs/codex-mcp/src/auth_elicitation.rs @@ -0,0 +1,347 @@ +//! Auth elicitation helpers. +//! +//! This module owns protocol-neutral auth elicitation parsing and payload shaping. +//! Session orchestration stays in `codex-core`. + +use codex_protocol::mcp::CallToolResult; +use serde::Serialize; + +pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason"; +pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexAppsConnectorAuthFailure { + pub connector_id: String, + pub connector_name: String, + pub install_url: String, + pub auth_reason: Option, + pub link_id: Option, + pub error_code: Option, + pub error_http_status_code: Option, + pub error_action: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitation { + pub meta: serde_json::Value, + pub message: String, + pub url: String, + pub elicitation_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitationPlan { + pub auth_failure: CodexAppsConnectorAuthFailure, + pub elicitation: CodexAppsAuthElicitation, +} + +#[derive(Serialize)] +struct CodexAppsConnectorAuthFailureMeta<'a> { + is_auth_failure: bool, + connector_id: &'a str, + connector_name: &'a str, + install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + auth_reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + link_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_http_status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_action: Option<&'a str>, +} + +pub fn connector_auth_failure_from_tool_result( + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + if result.is_error != Some(true) { + return None; + } + + let auth_failure = result + .meta + .as_ref()? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let connector_id = connector_id + .map(str::trim) + .filter(|connector_id| !connector_id.is_empty())?; + if let Some(auth_failure_connector_id) = + string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + && auth_failure_connector_id != connector_id + { + return None; + } + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(connector_id) + .to_string(); + + Some(CodexAppsConnectorAuthFailure { + connector_id: connector_id.to_string(), + connector_name, + install_url: install_url?, + auth_reason: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY, + ), + link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY), + error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY), + error_http_status_code: auth_failure + .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY) + .and_then(serde_json::Value::as_i64), + error_action: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY, + ), + }) +} + +pub fn build_auth_elicitation_plan( + call_id: &str, + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + let auth_failure = + connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?; + let elicitation = build_auth_elicitation(call_id, &auth_failure); + Some(CodexAppsAuthElicitationPlan { + auth_failure, + elicitation, + }) +} + +pub fn build_auth_elicitation( + call_id: &str, + auth_failure: &CodexAppsConnectorAuthFailure, +) -> CodexAppsAuthElicitation { + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta { + is_auth_failure: true, + connector_id: &auth_failure.connector_id, + connector_name: &auth_failure.connector_name, + install_url: &auth_failure.install_url, + auth_reason: auth_failure.auth_reason.as_deref(), + link_id: auth_failure.link_id.as_deref(), + error_code: auth_failure.error_code.as_deref(), + error_http_status_code: auth_failure.error_http_status_code, + error_action: auth_failure.error_action.as_deref(), + }, + }, + }), + message: auth_elicitation_message(auth_failure), + url: auth_failure.install_url.clone(), + elicitation_id: auth_elicitation_id(call_id), + } +} + +pub fn auth_elicitation_completed_result( + auth_failure: &CodexAppsConnectorAuthFailure, + meta: Option, +) -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": format!( + "Authentication for {} was requested and accepted. Retry this tool call now.", + auth_failure.connector_name + ), + })], + structured_content: None, + is_error: Some(true), + meta, + } +} + +pub fn auth_elicitation_id(call_id: &str) -> String { + format!("codex_apps_auth_{call_id}") +} + +fn string_auth_failure_field( + auth_failure: &serde_json::Map, + key: &str, +) -> Option { + auth_failure + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String { + match auth_failure.auth_reason.as_deref() { + Some("oauth_upgrade_required") => format!( + "Reconnect {} on ChatGPT to grant the permissions needed for this request.", + auth_failure.connector_name + ), + Some("reauthentication_required") => format!( + "Reconnect {} on ChatGPT to restore access for this request.", + auth_failure.connector_name + ), + Some("missing_link") => format!( + "Sign in to {} on ChatGPT to use it in Codex.", + auth_failure.connector_name + ), + _ => format!( + "Sign in to {} on ChatGPT to continue.", + auth_failure.connector_name + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Untrusted Calendar", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } + } + + #[test] + fn parses_auth_failure_from_trusted_connector_metadata() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + Some(CodexAppsConnectorAuthFailure { + connector_id: "connector_calendar".to_string(), + connector_name: "Google Calendar".to_string(), + install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar" + .to_string(), + auth_reason: Some("reauthentication_required".to_string()), + link_id: Some("link_123".to_string()), + error_code: Some("UNAUTHORIZED".to_string()), + error_http_status_code: Some(401), + error_action: Some("TRIGGER_REAUTHENTICATION".to_string()), + }) + ); + } + + #[test] + fn rejects_missing_or_mismatched_connector_ids() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + /*connector_id*/ None, + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + None + ); + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_drive"), + Some("Google Drive"), + Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()), + ), + None + ); + } + + #[test] + fn builds_url_elicitation_payload() { + let auth_failure = connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth failure"); + + assert_eq!( + build_auth_elicitation("call_123", &auth_failure), + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Google Calendar", + "install_url": + "https://chatgpt.com/apps/google-calendar/connector_calendar", + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + }), + message: "Reconnect Google Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + ); + } + + #[test] + fn builds_auth_elicitation_plan() { + let plan = build_auth_elicitation_plan( + "call_123", + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth elicitation plan"); + + assert_eq!(plan.auth_failure.connector_name, "Google Calendar"); + assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123"); + } +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 483a82796a..f93ac5e089 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -17,6 +17,7 @@ use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::elicitation::ElicitationRequestManager; +use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; use crate::rmcp_client::AsyncManagedClient; @@ -70,6 +71,7 @@ use url::Url; pub struct McpConnectionManager { clients: HashMap, server_origins: HashMap, + host_owned_codex_apps_enabled: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, } @@ -82,9 +84,11 @@ impl McpConnectionManager { Self { clients: HashMap::new(), server_origins: HashMap::new(), + host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), permission_profile.get().clone(), + /*reviewer*/ None, ), startup_cancellation_token: CancellationToken::new(), } @@ -116,6 +120,10 @@ impl McpConnectionManager { self.server_origins.get(server_name).map(String::as_str) } + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { + self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME + } + pub fn set_approval_policy(&self, approval_policy: &Constrained) { if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { *policy = approval_policy.value(); @@ -128,6 +136,14 @@ impl McpConnectionManager { } } + pub fn elicitations_auto_deny(&self) -> bool { + self.elicitation_requests.auto_deny() + } + + pub fn set_elicitations_auto_deny(&self, auto_deny: bool) { + self.elicitation_requests.set_auto_deny(auto_deny); + } + #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] pub async fn new( mcp_servers: &HashMap, @@ -140,15 +156,20 @@ impl McpConnectionManager { runtime_environment: McpRuntimeEnvironment, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + host_owned_codex_apps_enabled: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, + elicitation_reviewer: Option, ) -> (Self, CancellationToken) { let cancel_token = CancellationToken::new(); let mut clients = HashMap::new(); let mut server_origins = HashMap::new(); let mut join_set = JoinSet::new(); - let elicitation_requests = - ElicitationRequestManager::new(approval_policy.value(), initial_permission_profile); + let elicitation_requests = ElicitationRequestManager::new( + approval_policy.value(), + initial_permission_profile, + elicitation_reviewer, + ); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); let startup_submit_id = submit_id.clone(); let codex_apps_auth_provider = auth @@ -240,6 +261,7 @@ impl McpConnectionManager { let manager = Self { clients, server_origins, + host_owned_codex_apps_enabled, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), }; diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 69ff10e023..f2dc85cde0 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -203,8 +203,11 @@ fn elicitation_granular_policy_respects_never_and_config() { #[tokio::test] async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { - let manager = - ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled); + let manager = ElicitationRequestManager::new( + AskForApproval::Never, + PermissionProfile::Disabled, + /*reviewer*/ None, + ); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); @@ -233,8 +236,11 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { #[tokio::test] async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fields() { - let manager = - ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled); + let manager = ElicitationRequestManager::new( + AskForApproval::Never, + PermissionProfile::Disabled, + /*reviewer*/ None, + ); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index def12a9d63..a51cd7c623 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::SendElicitation; +use futures::future::BoxFuture; use futures::future::FutureExt; use rmcp::model::CreateElicitationRequestParams; use rmcp::model::ElicitationAction; @@ -31,22 +32,56 @@ use rmcp::model::RequestId; use tokio::sync::Mutex; use tokio::sync::oneshot; +#[derive(Debug, Clone)] +pub struct ElicitationReviewRequest { + pub server_name: String, + pub request_id: RequestId, + pub elicitation: CreateElicitationRequestParams, +} + +pub trait ElicitationReviewer: Send + Sync { + fn review( + &self, + request: ElicitationReviewRequest, + ) -> BoxFuture<'static, Result>>; +} + +pub type ElicitationReviewerHandle = Arc; + #[derive(Clone)] pub(crate) struct ElicitationRequestManager { requests: Arc>, pub(crate) approval_policy: Arc>, pub(crate) permission_profile: Arc>, + auto_deny: Arc>, + reviewer: Option, } impl ElicitationRequestManager { pub(crate) fn new( approval_policy: AskForApproval, permission_profile: PermissionProfile, + reviewer: Option, ) -> Self { Self { requests: Arc::new(Mutex::new(HashMap::new())), approval_policy: Arc::new(StdMutex::new(approval_policy)), permission_profile: Arc::new(StdMutex::new(permission_profile)), + auto_deny: Arc::new(StdMutex::new(false)), + reviewer, + } + } + + pub(crate) fn auto_deny(&self) -> bool { + self.auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false) + } + + pub(crate) fn set_auto_deny(&self, auto_deny: bool) { + if let Ok(mut current) = self.auto_deny.lock() { + *current = auto_deny; } } @@ -73,13 +108,29 @@ impl ElicitationRequestManager { let elicitation_requests = self.requests.clone(); let approval_policy = self.approval_policy.clone(); let permission_profile = self.permission_profile.clone(); + let auto_deny = self.auto_deny.clone(); + let reviewer = self.reviewer.clone(); Box::new(move |id, elicitation| { let elicitation_requests = elicitation_requests.clone(); let tx_event = tx_event.clone(); let server_name = server_name.clone(); let approval_policy = approval_policy.clone(); let permission_profile = permission_profile.clone(); + let auto_deny = auto_deny.clone(); + let reviewer = reviewer.clone(); async move { + let auto_deny = auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false); + if auto_deny { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + } + let approval_policy = approval_policy .lock() .map(|policy| *policy) @@ -109,6 +160,17 @@ impl ElicitationRequestManager { }); } + if let Some(reviewer) = reviewer.as_ref() { + let request = ElicitationReviewRequest { + server_name: server_name.clone(), + request_id: id.clone(), + elicitation: elicitation.clone(), + }; + if let Some(response) = reviewer.review(request).await? { + return Ok(response); + } + } + let request = match elicitation { CreateElicitationRequestParams::FormElicitationParams { meta, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d4ee60e89..83dc65dd06 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,4 +1,7 @@ pub use connection_manager::McpConnectionManager; +pub use elicitation::ElicitationReviewRequest; +pub use elicitation::ElicitationReviewer; +pub use elicitation::ElicitationReviewerHandle; pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY; pub use runtime::McpRuntimeEnvironment; pub use runtime::SandboxState; @@ -8,18 +11,30 @@ pub use mcp::CODEX_APPS_MCP_SERVER_NAME; pub use mcp::McpConfig; pub use mcp::ToolPluginProvenance; +pub use auth_elicitation::CodexAppsAuthElicitation; +pub use auth_elicitation::CodexAppsAuthElicitationPlan; +pub use auth_elicitation::CodexAppsConnectorAuthFailure; +pub use auth_elicitation::MCP_TOOL_CODEX_APPS_META_KEY; +pub use auth_elicitation::auth_elicitation_completed_result; +pub use auth_elicitation::auth_elicitation_id; +pub use auth_elicitation::build_auth_elicitation; +pub use auth_elicitation::build_auth_elicitation_plan; +pub use auth_elicitation::connector_auth_failure_from_tool_result; pub use codex_apps::CodexAppsToolsCacheKey; pub use codex_apps::codex_apps_tools_cache_key; +pub use codex_builtin_mcps::BuiltinMcpServerOptions; +pub use codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME; +pub use codex_builtin_mcps::configured_builtin_mcp_servers; pub use mcp::configured_mcp_servers; pub use mcp::effective_mcp_servers; +pub use mcp::host_owned_codex_apps_enabled; pub use mcp::tool_plugin_provenance; pub use mcp::with_codex_apps_mcp; pub use mcp::McpServerStatusSnapshot; pub use mcp::McpSnapshotDetail; pub use mcp::collect_mcp_server_status_snapshot_with_detail; -pub use mcp::collect_mcp_snapshot_from_manager; pub use mcp::read_mcp_resource; pub use mcp::McpAuthStatusEntry; @@ -39,6 +54,7 @@ pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::qualified_mcp_tool_name_prefix; pub use tools::declared_openai_file_input_param_names; +pub(crate) mod auth_elicitation; pub(crate) mod codex_apps; pub(crate) mod connection_manager; pub(crate) mod elicitation; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index d689ea9043..ae667c698a 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -31,7 +31,6 @@ use codex_protocol::mcp::Tool; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; -use codex_protocol::protocol::McpListToolsResponseEvent; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; @@ -129,7 +128,11 @@ pub struct McpConfig { /// ChatGPT auth is checked separately at runtime before the built-in apps /// MCP server is added. pub apps_enabled: bool, - /// User-configured and plugin-provided MCP servers keyed by server name. + /// Configured MCP servers keyed by server name. + /// + /// This includes product-owned built-ins, user-configured servers, and + /// plugin-provided servers. Runtime-only additions belong in + /// [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, @@ -198,7 +201,7 @@ pub fn with_codex_apps_mcp( auth: Option<&CodexAuth>, config: &McpConfig, ) -> HashMap { - if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) { + if host_owned_codex_apps_enabled(config, auth) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), codex_apps_mcp_server_config(config), @@ -209,6 +212,10 @@ pub fn with_codex_apps_mcp( servers } +pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool { + config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) +} + pub fn configured_mcp_servers(config: &McpConfig) -> HashMap { config.configured_mcp_servers.clone() } @@ -233,6 +240,7 @@ pub async fn read_mcp_resource( uri: &str, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), @@ -253,8 +261,10 @@ pub async fn read_mcp_resource( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance(config), auth, + /*elicitation_reviewer*/ None, ) .await; @@ -287,6 +297,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { @@ -318,8 +329,10 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth, + /*elicitation_reviewer*/ None, ) .await; @@ -335,18 +348,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( snapshot } -pub async fn collect_mcp_snapshot_from_manager( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, -) -> McpListToolsResponseEvent { - collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager, - auth_status_entries, - McpSnapshotDetail::Full, - ) - .await -} - pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { codex_apps_mcp_url_for_base_url( &config.chatgpt_base_url, @@ -583,44 +584,6 @@ async fn collect_mcp_server_status_snapshot_from_manager( } } -async fn collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, - detail: McpSnapshotDetail, -) -> McpListToolsResponseEvent { - let (tools, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_all_tools(), - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resources().await - } else { - HashMap::new() - } - }, - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resource_templates().await - } else { - HashMap::new() - } - }, - ); - - let tools = tools - .into_iter() - .filter_map(|(name, tool)| { - protocol_tool_from_rmcp_tool(&name, &tool.tool).map(|tool| (name, tool)) - }) - .collect::>(); - - McpListToolsResponseEvent { - tools, - resources: convert_mcp_resources(resources), - resource_templates: convert_mcp_resource_templates(resource_templates), - auth_statuses: auth_statuses_from_entries(&auth_status_entries), - } -} - #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 6406f9e1ff..d2eeaf927f 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -146,7 +146,7 @@ pub struct ConfigToml { #[serde(default)] pub permissions: Option, - /// Deprecated optional external command to spawn for end-user notifications. + /// Optional external command to spawn for end-user notifications. #[serde(default)] pub notify: Option>, @@ -176,7 +176,10 @@ pub struct ConfigToml { pub compact_prompt: Option, /// Optional commit attribution text for commit message co-author trailers. + /// This top-level setting only takes effect when `[features].codex_git_commit` + /// is enabled. /// + /// When enabled and unset, Codex uses `Codex `. /// Set to an empty string to disable automatic commit attribution. pub commit_attribution: Option, diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index d947ebb867..27cca781c7 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -25,6 +25,8 @@ pub struct HooksToml { pub struct HookStateToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_hash: Option, } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 93541ee7f8..69fcd3fe95 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -90,6 +90,7 @@ fn hooks_toml_deserializes_inline_events_and_state_map() { r#" [state."/tmp/hooks.json:pre_tool_use:0:0"] enabled = false +trusted_hash = "sha256:abc123" [[PreToolUse]] matcher = "^Bash$" @@ -120,6 +121,7 @@ command = "python3 /tmp/pre.py" "/tmp/hooks.json:pre_tool_use:0:0".to_string(), super::HookStateToml { enabled: Some(false), + trusted_hash: Some("sha256:abc123".to_string()), }, )]), } diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index f6f63191b5..fab78a128c 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -7,6 +7,7 @@ use crate::config_toml::ToolsToml; use crate::types::AnalyticsConfigToml; use crate::types::ApprovalsReviewer; use crate::types::Personality; +use crate::types::SessionPickerViewMode; use crate::types::WindowsToml; use codex_features::FeaturesToml; use codex_protocol::config_types::ReasoningSummary; @@ -63,6 +64,9 @@ pub struct ConfigProfile { pub tools: Option, pub web_search: Option, pub analytics: Option, + /// TUI settings scoped to this profile. + #[serde(default)] + pub tui: Option, #[serde(default)] pub windows: Option, /// Optional feature toggles scoped to this profile. @@ -73,6 +77,16 @@ pub struct ConfigProfile { pub oss_provider: Option, } +/// TUI settings supported inside a named profile. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct ProfileTui { + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, +} + impl From for codex_app_server_protocol::Profile { fn from(config_profile: ConfigProfile) -> Self { Self { diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index fcce1fa8ab..3e8be83d6a 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -106,6 +106,8 @@ pub struct TuiGlobalKeymap { pub toggle_vim_mode: Option, /// Toggle Fast mode. pub toggle_fast_mode: Option, + /// Toggle raw scrollback mode for copy-friendly transcript selection. + pub toggle_raw_output: Option, } /// Chat context keybindings. diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index b856367a66..6cb9abc507 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -57,6 +57,30 @@ const fn default_enabled() -> bool { true } +/// Preferred layout for the resume/fork session picker. +#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum SessionPickerViewMode { + Comfortable, + #[default] + Dense, +} + +impl SessionPickerViewMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Comfortable => "comfortable", + Self::Dense => "dense", + } + } +} + +impl fmt::Display for SessionPickerViewMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + /// Determine where Codex should store CLI auth credentials. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] @@ -617,6 +641,11 @@ pub struct Tui { #[serde(default)] pub vim_mode_default: bool, + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + /// Defaults to `false`. + #[serde(default)] + pub raw_output_mode: bool, + /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. @@ -656,6 +685,10 @@ pub struct Tui { #[serde(default)] pub theme: Option, + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, + /// Keybinding overrides for the TUI. /// /// This supports rebinding selected actions globally and by context. diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index aa68656d1b..9af459830a 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -18,6 +18,7 @@ pub use codex_config::types::ModelAvailabilityNuxConfig; pub use codex_config::types::Notice; pub use codex_config::types::OAuthCredentialsStoreMode; pub use codex_config::types::OtelConfig; +pub use codex_config::types::SessionPickerViewMode; pub use codex_config::types::ToolSuggestConfig; pub use codex_config::types::TuiKeymap; pub use codex_config::types::TuiNotificationSettings; @@ -30,6 +31,7 @@ pub use codex_core::StartThreadOptions; pub use codex_core::StateDbHandle; pub use codex_core::ThreadManager; pub use codex_core::ThreadShutdownReport; +pub use codex_core::agent_graph_store_from_state_db; pub use codex_core::config::Config; pub use codex_core::config::Constrained; pub use codex_core::config::GhostSnapshotConfig; @@ -39,6 +41,8 @@ pub use codex_core::config::TerminalResizeReflowConfig; pub use codex_core::config::ThreadStoreConfig; pub use codex_core::config::find_codex_home; pub use codex_core::init_state_db; +pub use codex_core::init_state_db_from_config; +pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsManager; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index b07b7da3e8..f348a3414d 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -569,6 +569,7 @@ async fn load_plugin( loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); let resolved_skills = load_plugin_skills( &plugin_root, + &loaded_plugin_id, manifest_paths, restriction_product, skill_config_rules, @@ -647,6 +648,7 @@ impl ResolvedPluginSkills { pub async fn load_plugin_skills( plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, manifest_paths: &PluginManifestPaths, restriction_product: Option, skill_config_rules: &SkillConfigRules, @@ -657,6 +659,7 @@ pub async fn load_plugin_skills( path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some(plugin_id.as_key()), }) .collect::>(); let outcome = load_skills_from_roots(roots).await; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index aecbd76e5c..2c974ef966 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -63,6 +63,7 @@ use codex_plugin::PluginIdError; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -222,6 +223,7 @@ pub struct PluginDetail { pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, pub installed: bool, pub enabled: bool, pub skills: Vec, @@ -251,6 +253,7 @@ pub struct ConfiguredMarketplacePlugin { pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, pub installed: bool, pub enabled: bool, } @@ -540,10 +543,10 @@ impl PluginsManager { &self, config_layer_stack: &ConfigLayerStack, config: &PluginsConfigInput, - ) -> Vec { + ) -> Vec { self.plugins_for_layer_stack(config_layer_stack, config, config.plugin_hooks_enabled) .await - .effective_skill_roots() + .effective_plugin_skill_roots() } fn cached_enabled_outcome( @@ -1195,6 +1198,7 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin.keywords, }) }) .collect::>(); @@ -1244,6 +1248,11 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin + .manifest + .as_ref() + .map(|manifest| manifest.keywords.clone()) + .unwrap_or_default(), installed: installed_plugins.contains(&plugin_key), enabled: enabled_plugins.contains(&plugin_key), }, @@ -1286,6 +1295,7 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: Vec::new(), @@ -1339,6 +1349,7 @@ impl PluginsManager { ); let resolved_skills = load_plugin_skills( &source_path, + &plugin_id, &manifest.paths, self.restriction_product, &codex_core_skills::config_rules::skill_config_rules_from_stack( @@ -1361,6 +1372,7 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface, + keywords: manifest.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: resolved_skills.skills, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 8abff7700b..50d0d46258 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1550,6 +1550,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: true, enabled: true, }, @@ -1566,6 +1567,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: true, enabled: false, }, @@ -1684,6 +1686,7 @@ plugins = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }] @@ -2068,6 +2071,7 @@ plugins = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }], @@ -2361,6 +2365,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: true, }] @@ -2390,6 +2395,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }] @@ -2473,6 +2479,7 @@ enabled = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: true, }], diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 12b738f537..6de7f820b8 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -18,6 +18,8 @@ struct RawPluginManifest { version: Option, #[serde(default)] description: Option, + #[serde(default)] + keywords: Vec, // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] @@ -37,6 +39,7 @@ pub struct PluginManifest { pub name: String, pub version: Option, pub description: Option, + pub keywords: Vec, pub paths: PluginManifestPaths, pub interface: Option, } @@ -143,6 +146,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option { name: raw_name, version, description, + keywords, skills, mcp_servers, apps, @@ -232,6 +236,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option { name, version, description, + keywords, paths: PluginManifestPaths { skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()), mcp_servers: resolve_manifest_path( @@ -568,6 +573,28 @@ mod tests { assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string())); } + #[test] + fn plugin_manifest_reads_keywords() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "keywords": ["api-key", "developer tools"] +}"#, + ) + .expect("write manifest"); + + let manifest = load_manifest(&plugin_root); + + assert_eq!( + manifest.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); + } + #[test] fn plugin_manifest_uses_alternate_discoverable_path() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index dc6d01adf9..f66b5d1b22 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -62,6 +62,7 @@ pub struct MarketplacePlugin { pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -293,6 +294,10 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -321,6 +329,8 @@ struct RemotePluginReleaseResponse { bundle_download_url: Option, #[serde(default)] app_ids: Vec, + #[serde(default)] + keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, @@ -771,6 +781,7 @@ fn build_remote_plugin_summary( auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), + keywords: plugin.release.keywords.clone(), } } diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 58df033cfb..0a254a1736 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -26,6 +26,46 @@ pub struct RemotePluginShareSaveResult { pub share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RemotePluginShareAccessPolicy { + pub discoverability: Option, + pub share_targets: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemotePluginShareDiscoverability { + Listed, + Unlisted, + Private, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginSharePrincipalType { + User, + Group, + Workspace, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemotePluginShareTarget { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginSharePrincipal { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareUpdateTargetsResult { + pub principals: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct RemoteWorkspacePluginUploadUrlRequest<'a> { filename: &'a str, @@ -46,6 +86,10 @@ struct RemoteWorkspacePluginUploadUrlResponse { struct RemoteWorkspacePluginCreateRequest { file_id: String, etag: String, + #[serde(skip_serializing_if = "Option::is_none")] + discoverability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + share_targets: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -54,12 +98,23 @@ struct RemoteWorkspacePluginCreateResponse { share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemotePluginShareUpdateTargetsRequest { + targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginShareUpdateTargetsResponse { + principals: Vec, +} + pub async fn save_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: &Path, plugin_path: &AbsolutePathBuf, remote_plugin_id: Option<&str>, + access_policy: RemotePluginShareAccessPolicy, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let plugin_path_for_archive = plugin_path.as_path().to_path_buf(); @@ -89,6 +144,8 @@ pub async fn save_remote_plugin_share( RemoteWorkspacePluginCreateRequest { file_id: upload.file_id, etag, + discoverability: access_policy.discoverability, + share_targets: access_policy.share_targets, }, ) .await?; @@ -173,6 +230,24 @@ pub async fn delete_remote_plugin_share( Ok(()) } +pub async fn update_remote_plugin_share_targets( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + remote_plugin_id: &str, + targets: Vec, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/public/plugins/{remote_plugin_id}/shares"); + let client = build_reqwest_client(); + let request = authenticated_request(client.put(&url), auth)? + .json(&RemotePluginShareUpdateTargetsRequest { targets }); + let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; + Ok(RemotePluginShareUpdateTargetsResult { + principals: response.principals, + }) +} + async fn fetch_created_workspace_plugins( config: &RemotePluginServiceConfig, auth: &CodexAuth, diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index efdecdbbbc..31ad5dd3fc 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -202,6 +202,17 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { .and(body_json(json!({ "file_id": "file_123", "etag": "\"upload_etag_123\"", + "discoverability": "PRIVATE", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "workspace-1", + }, + ], }))) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "plugin_id": "plugins_123", @@ -217,6 +228,19 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { codex_home.path(), &plugin_path, /*remote_plugin_id*/ None, + RemotePluginShareAccessPolicy { + discoverability: Some(RemotePluginShareDiscoverability::Private), + share_targets: Some(vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Workspace, + principal_id: "workspace-1".to_string(), + }, + ]), + }, ) .await .unwrap(); @@ -354,6 +378,7 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { codex_home.path(), &plugin_path, Some("plugins_123"), + RemotePluginShareAccessPolicy::default(), ) .await .unwrap(); @@ -367,6 +392,83 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { ); } +#[tokio::test] +async fn update_remote_plugin_share_targets_updates_targets() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("PUT")) + .and(path("/backend-api/public/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "group", + "principal_id": "group-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + { + "principal_type": "group", + "principal_id": "group-1", + "name": "Engineering", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let result = update_remote_plugin_share_targets( + &config, + Some(&auth), + "plugins_123", + vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }, + ], + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareUpdateTargetsResult { + principals: vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + name: "Engineering".to_string(), + }, + ], + } + ); +} + #[tokio::test] async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { let codex_home = TempDir::new().unwrap(); @@ -442,6 +544,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), local_plugin_path: Some(local_plugin_path), @@ -456,6 +559,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: None, local_plugin_path: None, diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index ed06cc578e..df62f42e85 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -59,6 +59,7 @@ pub async fn build_skill_injections( skill_name: skill.name.clone(), skill_scope: skill.scope, skill_path: skill.path_to_skills_md.to_path_buf(), + plugin_id: skill.plugin_id.clone(), invocation_type: InvocationType::Explicit, }); result.items.push(SkillInjection { diff --git a/codex-rs/core-skills/src/injection_tests.rs b/codex-rs/core-skills/src/injection_tests.rs index 9627318653..78aa195895 100644 --- a/codex-rs/core-skills/src/injection_tests.rs +++ b/codex-rs/core-skills/src/injection_tests.rs @@ -16,6 +16,7 @@ fn make_skill(name: &str, path: &str) -> SkillMetadata { policy: None, path_to_skills_md: test_path_buf(path).abs(), scope: codex_protocol::protocol::SkillScope::User, + plugin_id: None, } } diff --git a/codex-rs/core-skills/src/invocation_utils_tests.rs b/codex-rs/core-skills/src/invocation_utils_tests.rs index ab3a3e8dc0..f6e3883c16 100644 --- a/codex-rs/core-skills/src/invocation_utils_tests.rs +++ b/codex-rs/core-skills/src/invocation_utils_tests.rs @@ -21,6 +21,7 @@ fn test_skill_metadata(skill_doc_path: AbsolutePathBuf) -> SkillMetadata { policy: None, path_to_skills_md: skill_doc_path, scope: codex_protocol::protocol::SkillScope::User, + plugin_id: None, } } diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index d7a69e8a25..2473f7108c 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_plugins::PluginSkillRoot; use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; use serde::Deserialize; @@ -152,6 +153,7 @@ pub struct SkillRoot { pub path: AbsolutePathBuf, pub scope: SkillScope, pub file_system: Arc, + pub plugin_id: Option, } pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome @@ -167,7 +169,14 @@ where let root_path = canonicalize_for_skill_identity(&root.path); let fs = root.file_system; let skills_before_root = outcome.skills.len(); - discover_skills_under_root(fs.as_ref(), &root_path, root.scope, &mut outcome).await; + discover_skills_under_root( + fs.as_ref(), + &root_path, + root.scope, + root.plugin_id.as_deref(), + &mut outcome, + ) + .await; for skill in &outcome.skills[skills_before_root..] { if !skill_roots.contains(&root_path) { skill_roots.push(root_path.clone()); @@ -222,7 +231,7 @@ pub(crate) async fn skill_roots( fs: Option>, config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, - plugin_skill_roots: Vec, + plugin_skill_roots: Vec, ) -> Vec { let home_dir = home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok()); @@ -241,13 +250,14 @@ async fn skill_roots_with_home_dir( config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>, - plugin_skill_roots: Vec, + plugin_skill_roots: Vec, ) -> Vec { let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone()); - roots.extend(plugin_skill_roots.into_iter().map(|path| SkillRoot { - path, + roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot { + path: root.path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some(root.plugin_id), })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); @@ -276,6 +286,7 @@ fn skill_roots_from_layer_stack_inner( path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), + plugin_id: None, }); } } @@ -286,6 +297,7 @@ fn skill_roots_from_layer_stack_inner( path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); // `$HOME/.agents/skills` (user-installed skills). @@ -294,6 +306,7 @@ fn skill_roots_from_layer_stack_inner( path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); } @@ -303,6 +316,7 @@ fn skill_roots_from_layer_stack_inner( path: system_cache_root_dir(&config_folder), scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); } ConfigLayerSource::System { .. } => { @@ -312,6 +326,7 @@ fn skill_roots_from_layer_stack_inner( path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); } ConfigLayerSource::Mdm { .. } @@ -343,6 +358,7 @@ async fn repo_agents_skill_roots( path: agents_skills, scope: SkillScope::Repo, file_system: Arc::clone(&fs), + plugin_id: None, }), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} @@ -441,6 +457,7 @@ async fn discover_skills_under_root( fs: &dyn ExecutorFileSystem, root: &AbsolutePathBuf, scope: SkillScope, + plugin_id: Option<&str>, outcome: &mut SkillLoadOutcome, ) { let root = canonicalize_for_skill_identity(root); @@ -553,7 +570,7 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope).await { + match parse_skill_file(fs, &path, scope, plugin_id).await { Ok(skill) => { outcome.skills.push(skill); } @@ -583,6 +600,7 @@ async fn parse_skill_file( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, scope: SkillScope, + plugin_id: Option<&str>, ) -> Result { let contents = fs .read_file_text(path, /*sandbox*/ None) @@ -639,6 +657,7 @@ async fn parse_skill_file( policy, path_to_skills_md: resolved_path, scope, + plugin_id: plugin_id.map(str::to_string), }) } diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index a12f09f80f..c80585871e 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -322,6 +322,7 @@ async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<() policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); @@ -472,6 +473,7 @@ async fn loads_skill_dependencies_metadata_from_yaml() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -527,6 +529,7 @@ interface: policy: None, path_to_skills_md: normalized(skill_path.as_path()), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -680,6 +683,7 @@ async fn accepts_icon_paths_under_assets_dir() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -720,6 +724,7 @@ async fn ignores_invalid_brand_color() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -773,6 +778,7 @@ async fn ignores_default_prompt_over_max_length() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -814,6 +820,7 @@ async fn drops_interface_when_icons_are_invalid() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -858,6 +865,7 @@ async fn loads_skills_via_symlinked_subdir_for_user_scope() { policy: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -917,6 +925,7 @@ async fn does_not_loop_on_symlink_cycle_for_user_scope() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -936,6 +945,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { path: admin_root.path().abs(), scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }]) .await; @@ -955,6 +965,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { policy: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::Admin, + plugin_id: None, }] ); } @@ -994,6 +1005,7 @@ async fn loads_skills_via_symlinked_subdir_for_repo_scope() { policy: None, path_to_skills_md: normalized(&linked_skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1014,6 +1026,7 @@ async fn system_scope_ignores_symlinked_subdir() { path: system_root.abs(), scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }]) .await; assert!( @@ -1046,6 +1059,7 @@ async fn respects_max_scan_depth_for_user_scope() { path: skills_root.abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }]) .await; @@ -1065,6 +1079,7 @@ async fn respects_max_scan_depth_for_user_scope() { policy: None, path_to_skills_md: normalized(&within_depth_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1092,6 +1107,7 @@ async fn loads_valid_skill() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1124,6 +1140,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1148,6 +1165,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { path: plugin_root.join("skills").abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), }]) .await; @@ -1167,6 +1185,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: Some("sample@test".to_string()), }] ); } @@ -1198,6 +1217,7 @@ async fn loads_short_description_from_metadata() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1310,6 +1330,7 @@ async fn loads_skills_from_repo_root() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1345,6 +1366,7 @@ async fn loads_skills_from_agents_dir_without_codex_dir() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1398,6 +1420,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { policy: None, path_to_skills_md: normalized(&nested_skill_path), scope: SkillScope::Repo, + plugin_id: None, }, SkillMetadata { name: "root-skill".to_string(), @@ -1408,6 +1431,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { policy: None, path_to_skills_md: normalized(&root_skill_path), scope: SkillScope::Repo, + plugin_id: None, }, ] ); @@ -1447,6 +1471,7 @@ async fn loads_skills_from_codex_dir_when_not_git_repo() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1462,11 +1487,13 @@ async fn deduplicates_by_path_preferring_first_root() { path: root.path().abs(), scope: SkillScope::Repo, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }, SkillRoot { path: root.path().abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }, ]) .await; @@ -1487,6 +1514,7 @@ async fn deduplicates_by_path_preferring_first_root() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1528,6 +1556,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { policy: None, path_to_skills_md: normalized(&repo_skill_path), scope: SkillScope::Repo, + plugin_id: None, }, SkillMetadata { name: "dupe-skill".to_string(), @@ -1538,6 +1567,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { policy: None, path_to_skills_md: normalized(&user_skill_path), scope: SkillScope::User, + plugin_id: None, }, ] ); @@ -1600,6 +1630,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { policy: None, path_to_skills_md: first_path, scope: SkillScope::Repo, + plugin_id: None, }, SkillMetadata { name: "dupe-skill".to_string(), @@ -1610,6 +1641,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { policy: None, path_to_skills_md: second_path, scope: SkillScope::Repo, + plugin_id: None, }, ] ); @@ -1681,6 +1713,7 @@ async fn loads_skills_when_cwd_is_file_in_repo() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1739,6 +1772,7 @@ async fn loads_skills_from_system_cache_when_present() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::System, + plugin_id: None, }] ); } diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/manager.rs index b7b7a4b64d..73b1f14807 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/manager.rs @@ -8,6 +8,7 @@ use codex_exec_server::ExecutorFileSystem; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; use tracing::info; use tracing::warn; @@ -26,7 +27,7 @@ use codex_config::SkillsConfig; #[derive(Debug, Clone)] pub struct SkillsLoadInput { pub cwd: AbsolutePathBuf, - pub effective_skill_roots: Vec, + pub effective_skill_roots: Vec, pub config_layer_stack: ConfigLayerStack, pub bundled_skills_enabled: bool, } @@ -34,7 +35,7 @@ pub struct SkillsLoadInput { impl SkillsLoadInput { pub fn new( cwd: AbsolutePathBuf, - effective_skill_roots: Vec, + effective_skill_roots: Vec, config_layer_stack: ConfigLayerStack, bundled_skills_enabled: bool, ) -> Self { @@ -168,6 +169,7 @@ impl SkillsManager { path, scope: SkillScope::User, file_system: Arc::clone(&fs), + plugin_id: None, }), ); } @@ -239,7 +241,7 @@ impl SkillsManager { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { - roots: Vec<(AbsolutePathBuf, u8)>, + roots: Vec<(AbsolutePathBuf, u8, Option)>, skill_config_rules: SkillConfigRules, } @@ -279,7 +281,7 @@ fn config_skills_cache_key( SkillScope::System => 2, SkillScope::Admin => 3, }; - (root.path.clone(), scope_rank) + (root.path.clone(), scope_rank, root.plugin_id.clone()) }) .collect(), skill_config_rules: skill_config_rules.clone(), diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 73800a51d0..532d6951ec 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -12,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::PathExt; use codex_utils_absolute_path::test_support::test_path_buf; +use codex_utils_plugins::PluginSkillRoot; use pretty_assertions::assert_eq; use std::collections::HashSet; use std::fs; @@ -67,6 +68,7 @@ fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { .canonicalize() .expect("skill path should canonicalize"), scope: SkillScope::User, + plugin_id: None, } } @@ -146,7 +148,14 @@ async fn skills_for_config_with_stack( ) -> SkillLoadOutcome { let skills_input = SkillsLoadInput::new( cwd.path().abs(), - effective_skill_roots.to_vec(), + effective_skill_roots + .iter() + .cloned() + .map(|path| PluginSkillRoot { + path, + plugin_id: "test-plugin@test".to_string(), + }) + .collect(), config_layer_stack.clone(), bundled_skills_enabled_from_stack(config_layer_stack), ); diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index 0a72c24fe8..fc8e9f5917 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -19,6 +19,7 @@ pub struct SkillMetadata { /// Path to the SKILLS.md file that declares this skill. pub path_to_skills_md: AbsolutePathBuf, pub scope: SkillScope, + pub plugin_id: Option, } impl SkillMetadata { diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 613ed9cbe5..28617fb6c4 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -922,6 +922,7 @@ mod tests { policy: None, path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(), scope, + plugin_id: None, } } diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index dbca9ab63a..c78750576b 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -52,6 +52,7 @@ codex_rust_crate( test_tags = ["no-sandbox"], unit_test_timeout = "long", extra_binaries = [ + "//codex-rs/bwrap:bwrap", "//codex-rs/linux-sandbox:codex-linux-sandbox", "//codex-rs/rmcp-client:test_stdio_server", "//codex-rs/rmcp-client:test_streamable_http_server", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 44c6aacac5..49ed304623 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -26,6 +26,7 @@ bm25 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-analytics = { workspace = true } +codex-agent-graph-store = { workspace = true } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index be222a1673..3283ba2c3e 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -39,14 +39,14 @@ The Linux sandbox helper prefers the first `bwrap` found on `PATH` outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If -`bwrap` is missing, it falls back to the vendored bubblewrap path compiled into -the binary and Codex surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. Codex also surfaces -a startup warning when bubblewrap cannot create user namespaces. WSL2 uses the -normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing -because it cannot create the required user namespaces, so Codex rejects -sandboxed shell commands that would enter the bubblewrap path before invoking -`bwrap`. +`bwrap` is missing, it falls back to the bundled `codex-resources/bwrap` +binary shipped with Codex and Codex surfaces a startup warning through its +normal notification path instead of printing directly from the sandbox helper. +Codex also surfaces a startup warning when bubblewrap cannot create user +namespaces. WSL2 uses the normal Linux bubblewrap path. WSL1 is not supported +for bubblewrap sandboxing because it cannot create the required user +namespaces, so Codex rejects sandboxed shell commands that would enter the +bubblewrap path before invoking `bwrap`. ### Windows diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 824f542b7e..fe3e48a2df 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -370,12 +370,18 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, "browser_use_external": { "type": "boolean" }, + "builtin_mcp": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -517,6 +523,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -674,6 +683,15 @@ "tools_view_image": { "type": "boolean" }, + "tui": { + "allOf": [ + { + "$ref": "#/definitions/ProfileTui" + } + ], + "default": null, + "description": "TUI settings scoped to this profile." + }, "web_search": { "$ref": "#/definitions/WebSearchMode" }, @@ -999,6 +1017,9 @@ "properties": { "enabled": { "type": "boolean" + }, + "trusted_hash": { + "type": "string" } }, "type": "object" @@ -1873,6 +1894,22 @@ }, "type": "object" }, + "ProfileTui": { + "additionalProperties": false, + "description": "TUI settings supported inside a named profile.", + "properties": { + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + } + }, + "type": "object" + }, "ProjectConfig": { "additionalProperties": false, "properties": { @@ -2160,6 +2197,14 @@ ], "type": "string" }, + "SessionPickerViewMode": { + "description": "Preferred layout for the resume/fork session picker.", + "enum": [ + "comfortable", + "dense" + ], + "type": "string" + }, "ShellEnvironmentPolicyInherit": { "oneOf": [ { @@ -2460,6 +2505,7 @@ "queue": null, "submit": null, "toggle_fast_mode": null, + "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_vim_mode": null }, @@ -2558,6 +2604,20 @@ "default": true, "description": "Enable desktop notifications from the TUI. Defaults to `true`." }, + "raw_output_mode": { + "default": false, + "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", + "type": "boolean" + }, + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + }, "show_tooltips": { "default": true, "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", @@ -2956,6 +3016,14 @@ ], "description": "Toggle Fast mode." }, + "toggle_raw_output": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle raw scrollback mode for copy-friendly transcript selection." + }, "toggle_shortcuts": { "allOf": [ { @@ -3062,6 +3130,7 @@ "queue": null, "submit": null, "toggle_fast_mode": null, + "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_vim_mode": null } @@ -3769,7 +3838,7 @@ "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, "commit_attribution": { - "description": "Optional commit attribution text for commit message co-author trailers.\n\nSet to an empty string to disable automatic commit attribution.", + "description": "Optional commit attribution text for commit message co-author trailers. This top-level setting only takes effect when `[features].codex_git_commit` is enabled.\n\nWhen enabled and unset, Codex uses `Codex `. Set to an empty string to disable automatic commit attribution.", "type": "string" }, "compact_prompt": { @@ -3859,12 +3928,18 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, "browser_use_external": { "type": "boolean" }, + "builtin_mcp": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -4006,6 +4081,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -4294,7 +4372,7 @@ }, "notify": { "default": null, - "description": "Deprecated optional external command to spawn for end-user notifications.", + "description": "Optional external command to spawn for end-user notifications.", "items": { "type": "string" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 69609434c1..b2333f1790 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -14,6 +14,7 @@ use crate::thread_manager::ThreadManagerState; use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use codex_features::Feature; use codex_protocol::AgentPath; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -27,9 +28,9 @@ use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; -use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_thread_store::ReadThreadParams; use serde::Serialize; use std::collections::HashMap; @@ -132,6 +133,9 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { /// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. #[derive(Clone, Default)] pub(crate) struct AgentControl { + /// ID shared by the whole agent control session. This means every sub-agents from a common + /// root share the same session ID. + session_id: SessionId, /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. @@ -148,6 +152,15 @@ impl AgentControl { } } + pub(crate) fn with_session_id(mut self, session_id: SessionId) -> Self { + self.session_id = session_id; + self + } + + pub(crate) fn session_id(&self) -> SessionId { + self.session_id + } + /// Spawn a new agent thread and submit the initial prompt. #[cfg(test)] pub(crate) async fn spawn_agent( @@ -235,6 +248,7 @@ impl AgentControl { config.clone(), self.clone(), session_source, + /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, /*metrics_service_name*/ None, inherited_shell_snapshot, @@ -296,7 +310,6 @@ impl AgentControl { state.notify_thread_created(new_thread.thread_id); self.persist_thread_spawn_edge_for_source( - new_thread.thread.as_ref(), new_thread.thread_id, notification_source.as_ref(), ) @@ -356,14 +369,10 @@ impl AgentControl { let parent_thread_id = *parent_thread_id; let parent_thread = state.get_thread(parent_thread_id).await.ok(); if let Some(parent_thread) = parent_thread.as_ref() { - // `record_conversation_items` only queues rollout writes asynchronously. - // Flush/materialize the live parent before snapshotting JSONL for a fork. - parent_thread - .codex - .session - .ensure_rollout_materialized() - .await; - parent_thread.codex.session.flush_rollout().await?; + // `record_conversation_items` only queues persistence writes asynchronously. + // Flush before snapshotting store history for a fork. + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await?; } let parent_history = state @@ -426,6 +435,7 @@ impl AgentControl { InitialHistory::Forked(forked_rollout_items), self.clone(), session_source, + /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, inherited_shell_snapshot, inherited_exec_policy, @@ -449,19 +459,14 @@ impl AgentControl { )) .await?; let state = self.upgrade()?; - let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { - return Ok(resumed_thread_id); - }; - let Some(state_db_ctx) = resumed_thread.state_db() else { - return Ok(resumed_thread_id); - }; + let agent_graph_store = state.agent_graph_store(); let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { - let child_ids = match state_db_ctx - .list_thread_spawn_children_with_status( + let child_ids = match agent_graph_store + .list_thread_spawn_children( parent_thread_id, - DirectionalThreadSpawnEdgeStatus::Open, + Some(codex_agent_graph_store::ThreadSpawnEdgeStatus::Open), ) .await { @@ -525,7 +530,6 @@ impl AgentControl { let _ = config.features.disable(Feature::Collab); } let state = self.upgrade()?; - let state_db_ctx = state.state_db(); let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; let (session_source, agent_metadata) = match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -535,14 +539,11 @@ impl AgentControl { agent_role: _, agent_nickname: _, }) => { + let state_db_ctx = state.state_db(); let (resumed_agent_nickname, resumed_agent_role) = - if let Some(state_db_ctx) = state_db_ctx.as_ref() { - match state_db_ctx.get_thread(thread_id).await { - Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), - Ok(None) | Err(_) => (None, None), - } - } else { - (None, None) + match state_db_ctx.get_thread(thread_id).await { + Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), + Ok(None) | Err(_) => (None, None), }; self.prepare_thread_spawn( &mut reservation, @@ -609,7 +610,6 @@ impl AgentControl { ); } self.persist_thread_spawn_edge_for_source( - resumed_thread.thread.as_ref(), resumed_thread.thread_id, Some(¬ification_source), ) @@ -722,11 +722,13 @@ impl AgentControl { /// agent and any live descendants reached from the in-memory tree. pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { let state = self.upgrade()?; - if let Ok(thread) = state.get_thread(agent_id).await - && let Some(state_db_ctx) = thread.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status(agent_id, DirectionalThreadSpawnEdgeStatus::Closed) - .await + if let Err(err) = state + .agent_graph_store() + .set_thread_spawn_edge_status( + agent_id, + codex_agent_graph_store::ThreadSpawnEdgeStatus::Closed, + ) + .await { warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); } @@ -1142,21 +1144,21 @@ impl AgentControl { async fn persist_thread_spawn_edge_for_source( &self, - thread: &crate::CodexThread, child_thread_id: ThreadId, session_source: Option<&SessionSource>, ) { let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else { return; }; - let Some(state_db_ctx) = thread.state_db() else { + let Ok(state) = self.upgrade() else { return; }; - if let Err(err) = state_db_ctx + if let Err(err) = state + .agent_graph_store() .upsert_thread_spawn_edge( parent_thread_id, child_thread_id, - DirectionalThreadSpawnEdgeStatus::Open, + codex_agent_graph_store::ThreadSpawnEdgeStatus::Open, ) .await { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b95aad4489..a8de427814 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1,6 +1,5 @@ use super::*; use crate::CodexThread; -use crate::StateDbHandle; use crate::ThreadManager; use crate::agent::agent_status_from_event; use crate::config::AgentRoleConfig; @@ -8,7 +7,6 @@ use crate::config::Config; use crate::config::ConfigBuilder; use crate::context::ContextualUserFragment; use crate::context::SubagentNotification; -use crate::init_state_db; use assert_matches::assert_matches; use codex_features::Feature; use codex_login::CodexAuth; @@ -86,7 +84,6 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem { struct AgentControlHarness { _home: TempDir, config: Config, - state_db: Option, manager: ThreadManager, control: AgentControl, } @@ -94,19 +91,17 @@ struct AgentControlHarness { impl AgentControlHarness { async fn new() -> Self { let (home, config) = test_config().await; - let state_db = init_state_db(&config).await; - let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + let manager = ThreadManager::with_models_provider_and_home_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - state_db.clone(), - ); + ) + .await; let control = manager.agent_control(); Self { _home: home, config, - state_db, manager, control, } @@ -955,7 +950,8 @@ async fn spawn_agent_respects_max_threads_limit() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); + ) + .await; let control = manager.agent_control(); let _ = manager @@ -1007,7 +1003,8 @@ async fn spawn_agent_releases_slot_after_shutdown() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); + ) + .await; let control = manager.agent_control(); let first_agent_id = control @@ -1050,7 +1047,8 @@ async fn spawn_agent_limit_shared_across_clones() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); + ) + .await; let control = manager.agent_control(); let cloned = control.clone(); @@ -1095,7 +1093,8 @@ async fn resume_agent_respects_max_threads_limit() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); + ) + .await; let control = manager.agent_control(); let resumable_id = control @@ -1151,7 +1150,8 @@ async fn resume_agent_releases_slot_after_resume_failure() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); + ) + .await; let control = manager.agent_control(); let _ = control @@ -1543,19 +1543,17 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { .features .enable(Feature::Sqlite) .expect("test config should allow sqlite"); - let state_db = init_state_db(&config).await; - let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + let manager = ThreadManager::with_models_provider_and_home_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - state_db.clone(), - ); + ) + .await; let control = manager.agent_control(); let harness = AgentControlHarness { _home: home, config, - state_db, manager, control, }; @@ -1706,7 +1704,12 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() { .expect("child shutdown should succeed"); let store = LocalThreadStore::new( LocalThreadStoreConfig::from_config(&harness.config), - harness.state_db.clone(), + codex_state::StateRuntime::init( + harness.config.sqlite_home.clone(), + harness.config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), ); store .archive_thread(ArchiveThreadParams { diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 2550d58f82..1c99fb5950 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -657,7 +657,7 @@ enabled = false SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true); let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; - let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); let outcome = skills_manager .skills_for_config( diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d11fda47dc..39e6e85e20 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -58,7 +58,7 @@ use codex_api::SseTelemetry; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::auth_header_telemetry; -use codex_api::build_conversation_headers; +use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; use codex_app_server_protocol::AuthMode; @@ -70,9 +70,9 @@ use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; @@ -100,6 +100,7 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; +use tracing::debug; use tracing::instrument; use tracing::trace; use tracing::warn; @@ -146,13 +147,20 @@ const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); +pub(crate) struct CompactConversationRequestSettings { + pub(crate) effort: Option, + pub(crate) summary: ReasoningSummaryConfig, + pub(crate) service_tier: Option, +} + /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most /// configuration is per turn and is passed explicitly to streaming/unary methods. #[derive(Debug)] struct ModelClientState { - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, window_generation: AtomicU64, installation_id: String, provider: SharedModelProvider, @@ -190,7 +198,7 @@ impl RequestRouteTelemetry { /// A session-scoped client for model-provider API calls. /// /// This holds configuration and state that should be shared across turns within a Codex session -/// (auth, provider selection, conversation id, and transport fallback state). +/// (auth, provider selection, thread id, and transport fallback state). /// /// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns /// will also use HTTP for the remainder of the session. @@ -297,7 +305,8 @@ impl ModelClient { /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. pub fn new( auth_manager: Option>, - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, installation_id: String, provider_info: ModelProviderInfo, session_source: SessionSource, @@ -315,7 +324,8 @@ impl ModelClient { collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled); Self { state: Arc::new(ModelClientState { - conversation_id, + session_id, + thread_id, window_generation: AtomicU64::new(0), installation_id, provider: model_provider, @@ -360,9 +370,9 @@ impl ModelClient { } fn current_window_id(&self) -> String { - let conversation_id = self.state.conversation_id; + let thread_id = self.state.thread_id; let window_generation = self.state.window_generation.load(Ordering::Relaxed); - format!("{conversation_id}:{window_generation}") + format!("{thread_id}:{window_generation}") } fn take_cached_websocket_session(&self) -> WebsocketSession { @@ -410,12 +420,11 @@ impl ModelClient { /// /// The model selection and telemetry context are passed explicitly to keep `ModelClient` /// session-scoped. - pub async fn compact_conversation_history( + pub(crate) async fn compact_conversation_history( &self, prompt: &Prompt, model_info: &ModelInfo, - effort: Option, - summary: ReasoningSummaryConfig, + settings: CompactConversationRequestSettings, session_telemetry: &SessionTelemetry, compaction_trace: &CompactionTraceContext, ) -> Result> { @@ -438,9 +447,9 @@ impl ModelClient { &client_setup.api_provider, prompt, model_info, - effort, - summary, - /*service_tier*/ None, + settings.effort, + settings.summary, + settings.service_tier, )?; let ResponsesApiRequest { model, @@ -449,6 +458,8 @@ impl ModelClient { tools, parallel_tool_calls, reasoning, + service_tier, + prompt_cache_key, text, .. } = request; @@ -462,6 +473,8 @@ impl ModelClient { tools, parallel_tool_calls, reasoning, + service_tier: service_tier.as_deref(), + prompt_cache_key: prompt_cache_key.as_deref(), text, }; @@ -475,9 +488,10 @@ impl ModelClient { /*turn_metadata_header*/ None, )); extra_headers.extend(self.build_responses_identity_headers()); - extra_headers.extend(build_conversation_headers(Some( - self.state.conversation_id.to_string(), - ))); + extra_headers.extend(build_session_headers( + Some(self.state.session_id.to_string()), + Some(self.state.thread_id.to_string()), + )); let trace_attempt = compaction_trace.start_attempt(&payload); let result = client .compact_input(&payload, extra_headers) @@ -669,7 +683,7 @@ impl ModelClient { model_info: &ModelInfo, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, ) -> Result { let instructions = &prompt.base_instructions.text; let input = prompt.get_formatted_input(); @@ -696,7 +710,7 @@ impl ModelClient { &prompt.output_schema, prompt.output_schema_strict, ); - let prompt_cache_key = Some(self.state.conversation_id.to_string()); + let prompt_cache_key = Some(self.state.thread_id.to_string()); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions: instructions.clone(), @@ -708,11 +722,7 @@ impl ModelClient { store: provider.is_azure_responses_endpoint(), stream: true, include, - service_tier: match service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - }, + service_tier, prompt_cache_key, text, client_metadata: Some(HashMap::from([( @@ -850,16 +860,17 @@ impl ModelClient { turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.state.conversation_id.to_string(); + let session_id = self.state.session_id.to_string(); + let thread_id = self.state.thread_id.to_string(); let mut headers = build_responses_headers( self.state.beta_features_header.as_deref(), turn_state, turn_metadata_header.as_ref(), ); - if let Ok(header_value) = HeaderValue::from_str(&conversation_id) { + if let Ok(header_value) = HeaderValue::from_str(&thread_id) { headers.insert("x-client-request-id", header_value); } - headers.extend(build_conversation_headers(Some(conversation_id))); + headers.extend(build_session_headers(Some(session_id), Some(thread_id))); headers.extend(self.build_responses_identity_headers()); headers.insert( OPENAI_BETA_HEADER, @@ -892,6 +903,18 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } + pub(crate) async fn send_response_processed(&self, response_id: &str) { + let Some(connection) = self.websocket_session.connection.as_ref() else { + return; + }; + if let Err(err) = connection + .send_response_processed(response_id.to_string()) + .await + { + debug!("failed to send response.processed websocket request: {err}"); + } + } + #[allow(clippy::too_many_arguments)] /// Builds shared Responses API transport options and request-body options. /// @@ -903,9 +926,11 @@ impl ModelClientSession { compression: Compression, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.client.state.conversation_id.to_string(); + let session_id = self.client.state.session_id.to_string(); + let thread_id = self.client.state.thread_id.to_string(); ApiResponsesOptions { - conversation_id: Some(conversation_id), + session_id: Some(session_id), + thread_id: Some(thread_id), session_source: Some(self.client.state.session_source.clone()), extra_headers: { let mut headers = build_responses_headers( @@ -1151,7 +1176,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { @@ -1198,7 +1223,7 @@ impl ModelClientSession { model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.record_started(&request); @@ -1276,7 +1301,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, warmup: bool, request_trace: Option, @@ -1304,7 +1329,7 @@ impl ModelClientSession { model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( @@ -1436,7 +1461,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result<()> { if !self.client.responses_websocket_enabled() { @@ -1497,7 +1522,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { @@ -1513,7 +1538,7 @@ impl ModelClientSession { session_telemetry, effort, summary, - service_tier, + service_tier.clone(), turn_metadata_header, /*warmup*/ false, request_trace, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 13c5603baa..2ba65d7c45 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -52,9 +52,11 @@ use tracing_subscriber::util::SubscriberInitExt; fn test_model_client(session_source: SessionSource) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); + let thread_id = ThreadId::new(); ModelClient::new( /*auth_manager*/ None, - ThreadId::new(), + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, @@ -270,7 +272,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { client.advance_window_generation(); let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); - let conversation_id = client.state.conversation_id; + let thread_id = client.state.thread_id; assert_eq!( client_metadata, std::collections::HashMap::from([ @@ -280,7 +282,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { ), ( X_CODEX_WINDOW_ID_HEADER.to_string(), - format!("{conversation_id}:1"), + format!("{thread_id}:1"), ), ( X_OPENAI_SUBAGENT_HEADER.to_string(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1aeb125091..3ead350dfa 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadSource; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsArgs; use codex_protocol::request_permissions::RequestPermissionsEvent; @@ -75,6 +76,7 @@ pub(crate) async fn run_codex_thread_interactive( let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, + installation_id: parent_session.installation_id.clone(), auth_manager, models_manager, environment_manager: Arc::clone(&parent_session.services.environment_manager), @@ -84,6 +86,7 @@ pub(crate) async fn run_codex_thread_interactive( skills_watcher: Arc::clone(&parent_session.services.skills_watcher), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source.clone()), + thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), persist_extended_history: false, @@ -95,6 +98,7 @@ pub(crate) async fn run_codex_thread_interactive( parent_trace: None, environment_selections: parent_ctx.environments.clone(), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), + state_db: parent_session.services.state_db.clone(), thread_store: Arc::clone(&parent_session.services.thread_store), })) .or_cancel(&cancel_token) @@ -265,10 +269,6 @@ async fn forward_events( id: _, msg: EventMsg::SessionConfigured(_), } => {} - Event { - id: _, - msg: EventMsg::ThreadNameUpdated(_), - } => {} Event { id, msg: EventMsg::ExecApprovalRequest(event), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index cc83c0a7c1..24a976637b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,7 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use crate::goals::ExternalGoalSet; use crate::goals::GoalRuntimeEvent; use crate::session::Codex; use crate::session::SessionSettingsUpdate; @@ -10,7 +11,6 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -29,10 +29,13 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; +use codex_thread_store::StoredThread; use codex_thread_store::StoredThreadHistory; +use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; @@ -49,7 +52,7 @@ use codex_rollout::state_db::StateDbHandle; pub struct ThreadConfigSnapshot { pub model: String, pub model_provider_id: String, - pub service_tier: Option, + pub service_tier: Option, pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, @@ -59,6 +62,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub thread_source: Option, } impl ThreadConfigSnapshot { @@ -86,7 +90,7 @@ pub struct CodexThreadTurnContextOverrides { pub model: Option, pub effort: Option>, pub summary: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub collaboration_mode: Option, pub personality: Option, } @@ -158,11 +162,11 @@ impl CodexThread { } } - pub async fn apply_external_goal_set(&self, status: codex_state::ThreadGoalStatus) { + pub async fn apply_external_goal_set(&self, external_set: ExternalGoalSet) { if let Err(err) = self .codex .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { status }) + .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { external_set }) .await { tracing::warn!("failed to apply external goal status runtime effects: {err}"); @@ -218,9 +222,14 @@ impl CodexThread { &self, app_server_client_name: Option, app_server_client_version: Option, + mcp_elicitations_auto_deny: bool, ) -> ConstraintResult<()> { self.codex - .set_app_server_client_info(app_server_client_name, app_server_client_version) + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) .await } @@ -381,7 +390,7 @@ impl CodexThread { self.rollout_path.clone() } - pub(crate) fn session_configured(&self) -> SessionConfiguredEvent { + pub fn session_configured(&self) -> SessionConfiguredEvent { self.session_configured.clone() } @@ -411,6 +420,38 @@ impl CodexThread { live_thread.load_history(include_archived).await } + pub async fn read_thread( + &self, + include_archived: bool, + include_history: bool, + ) -> ThreadStoreResult { + let live_thread = self + .codex + .session + .live_thread_for_persistence("read thread") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread + .read_thread(include_archived, include_history) + .await + } + + pub async fn update_thread_metadata( + &self, + patch: ThreadMetadataPatch, + include_archived: bool, + ) -> ThreadStoreResult { + let live_thread = self + .codex + .session + .live_thread_for_persistence("update thread metadata") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread.update_metadata(patch, include_archived).await + } + pub fn state_db(&self) -> Option { self.codex.state_db() } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a4197acccd..38eb19f1ba 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -524,7 +524,7 @@ async fn drain_to_completed( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, // Rollout tracing currently models remote compaction only; local compaction streams // are left untraced until the reducer has a first-class local compaction lifecycle. diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 1a3f0d4a7a..bc0516ce01 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; +use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; @@ -170,8 +171,11 @@ async fn run_remote_compact_task_inner_impl( .compact_conversation_history( &prompt, &turn_context.model_info, - turn_context.reasoning_effort, - turn_context.reasoning_summary, + CompactConversationRequestSettings { + effort: turn_context.reasoning_effort, + summary: turn_context.reasoning_summary, + service_tier: turn_context.config.service_tier.clone(), + }, &turn_context.session_telemetry, &compaction_trace, ) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index f461dd9ce1..7705616d37 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -252,7 +252,7 @@ async fn run_remote_compaction_request_v2( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, &InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b48cf020f2..9b785ff337 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6,6 +6,7 @@ use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; use codex_config::RequirementSource; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; @@ -44,6 +45,7 @@ use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::SandboxWorkspaceWrite; +use codex_config::types::SessionPickerViewMode; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; @@ -60,6 +62,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; @@ -193,12 +196,13 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { "\n global instructions \n", )?; - let config = Config::load_from_base_config_with_overrides( + let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; + let _ = config.features.enable(Feature::MemoryTool); assert_eq!( config.user_instructions.as_deref(), @@ -550,11 +554,13 @@ fn config_toml_deserializes_model_availability_nux() { animations: true, show_tooltips: true, vim_mode_default: false, + raw_output_mode: false, alternate_screen: AltScreenMode::default(), status_line: None, status_line_use_colors: true, terminal_title: None, theme: None, + session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ @@ -660,6 +666,53 @@ fn test_tui_vim_mode_default_true() { ); } +#[test] +fn test_tui_raw_output_mode_defaults_to_false() { + let toml = r#" + [tui] + "#; + let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table"); + assert!( + !parsed + .tui + .expect("config should include tui section") + .raw_output_mode + ); +} + +#[test] +fn test_tui_raw_output_mode_true() { + let toml = r#" + [tui] + raw_output_mode = true + "#; + let parsed: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true"); + assert!( + parsed + .tui + .expect("config should include tui section") + .raw_output_mode + ); +} + +#[tokio::test] +async fn runtime_config_uses_tui_raw_output_mode() { + let toml = r#" + [tui] + raw_output_mode = true + "#; + let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true"); + let cfg = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load config"); + + assert!(cfg.tui_raw_output_mode); +} + #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" @@ -2108,6 +2161,31 @@ fn tui_theme_defaults_to_none() { assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); } +#[test] +fn tui_session_picker_view_deserializes_from_toml() { + let cfg = r#" +[tui] +session_picker_view = "dense" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.session_picker_view), + Some(SessionPickerViewMode::Dense), + ); +} + +#[test] +fn tui_session_picker_view_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.session_picker_view), + None, + ); +} + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2125,11 +2203,13 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { animations: true, show_tooltips: true, vim_mode_default: false, + raw_output_mode: false, alternate_screen: AltScreenMode::Auto, status_line: None, status_line_use_colors: true, terminal_title: None, theme: None, + session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow_max_rows: None, @@ -2195,6 +2275,78 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() ); } +#[test] +fn profile_tui_rejects_unsupported_settings() { + let err = toml::from_str::( + r#"profile = "work" + +[profiles.work.tui] +theme = "dark" +"#, + ) + .expect_err("profile TUI config should only accept supported fields"); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("theme")); +} + +#[tokio::test] +async fn runtime_config_resolves_session_picker_view_default_and_override() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load default config"); + + assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + session_picker_view: Some(SessionPickerViewMode::Comfortable), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load root override config"); + + assert_eq!( + cfg.tui_session_picker_view, + SessionPickerViewMode::Comfortable + ); + + let cfg_toml = toml::from_str::( + r#"profile = "work" + +[tui] +session_picker_view = "dense" + +[profiles.work.tui] +session_picker_view = "comfortable" +"#, + ) + .expect("parse profile scoped tui config"); + + let cfg = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load profile override config"); + + assert_eq!( + cfg.tui_session_picker_view, + SessionPickerViewMode::Comfortable + ); +} + #[tokio::test] async fn test_sandbox_config_parsing() { let sandbox_full_access = r#" @@ -2696,6 +2848,307 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { ); } +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let project_dot_codex = + AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path()); + let mcp_requirements = BTreeMap::from([ + ( + "session_overrides_user".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "session-command".to_string(), + }, + }, + ), + ( + "managed_overrides_session".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "managed-command".to_string(), + }, + }, + ), + ( + "fresh_global".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-global-command".to_string(), + }, + }, + ), + ( + "fresh_project".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-project-command".to_string(), + }, + }, + ), + ]); + let requirements_toml = codex_config::ConfigRequirementsToml { + mcp_servers: Some(mcp_requirements.clone()), + ..Default::default() + }; + let requirements = codex_config::ConfigRequirements { + mcp_servers: Some(Sourced::new(mcp_requirements, RequirementSource::Unknown)), + ..Default::default() + }; + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "new-user-command" + [mcp_servers.managed_overrides_session] + command = "new-user-command" + [mcp_servers.fresh_global] + command = "fresh-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex.clone(), + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "fresh-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "managed-command" + } + .into(), + ), + ], + requirements, + requirements_toml, + ) + .map_err(std::io::Error::other)?; + let refreshed_toml = refreshed_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "old-user-command" + [mcp_servers.managed_overrides_session] + command = "old-user-command" + [mcp_servers.fresh_global] + command = "old-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "old-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::SessionFlags, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "session-command" + [mcp_servers.managed_overrides_session] + command = "session-command" + [mcp_servers.blocked_session] + command = "blocked-session-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "old-managed-command" + } + .into(), + ), + ], + Default::default(), + Default::default(), + ) + .map_err(std::io::Error::other)?; + let thread_toml = thread_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + + assert_eq!( + config.mcp_servers.get(), + &HashMap::from([ + ( + "session_overrides_user".to_string(), + stdio_mcp("session-command"), + ), + ( + "managed_overrides_session".to_string(), + stdio_mcp("managed-command"), + ), + ( + "fresh_global".to_string(), + stdio_mcp("fresh-global-command"), + ), + ( + "fresh_project".to_string(), + stdio_mcp("fresh-project-command"), + ), + ( + "blocked_session".to_string(), + McpServerConfig { + enabled: false, + disabled_reason: Some(McpServerDisabledReason::Requirements { + source: RequirementSource::Unknown, + }), + ..stdio_mcp("blocked-session-command") + }, + ), + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() +-> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + )?; + + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [features] + plugins = true + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { file: user_file }, + toml::toml! { + [features] + plugins = false + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config.configured_mcp_servers.get("sample"), + Some(&http_mcp("https://sample.example/mcp")) + ); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -2893,6 +3346,77 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn to_mcp_config_empty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + mcp_servers: Some(BTreeMap::new()), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + config.codex_self_exe = Some(PathBuf::from("/tmp/codex")); + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some((true, None)) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_nonempty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "docs".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "docs-mcp".to_string(), + }, + }, + )])), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + config.codex_self_exe = Some(PathBuf::from("/tmp/codex")); + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some((true, None)) + ); + + Ok(()) +} + #[tokio::test] async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3715,6 +4239,107 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( Ok(()) } +#[tokio::test] +async fn to_mcp_config_includes_enabled_builtin_mcps() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| &server.transport), + Some(&McpServerTransportConfig::Stdio { + command: "/tmp/codex".to_string(), + args: vec![ + "builtin-mcp".to_string(), + "memories".to_string(), + "--codex-home".to_string(), + codex_home.path().display().to_string(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_omits_builtin_mcps_when_feature_is_disabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert!( + !mcp_config + .configured_mcp_servers + .contains_key(codex_mcp::MEMORIES_MCP_SERVER_NAME) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_reserves_enabled_builtin_mcp_names() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml { + mcp_servers: HashMap::from([( + codex_mcp::MEMORIES_MCP_SERVER_NAME.to_string(), + http_mcp("https://user.example/memories"), + )]), + ..ConfigToml::default() + }, + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert!(matches!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| &server.transport), + Some(McpServerTransportConfig::Stdio { .. }) + )); + + Ok(()) +} + #[tokio::test] async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -6450,6 +7075,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6461,6 +7087,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }, o3_profile_config @@ -6486,6 +7113,39 @@ async fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + let mut cfg = fixture.cfg.clone(); + cfg.otel = Some(OtelConfigToml { + exporter: Some(OtelExporterKind::OtlpHttp { + endpoint: "http://localhost:14318/v1/logs".to_string(), + headers: HashMap::new(), + protocol: codex_config::types::OtelHttpProtocol::Binary, + tls: None, + }), + metrics_exporter: Some(OtelExporterKind::None), + ..Default::default() + }); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert!(matches!( + config.otel.exporter, + OtelExporterKind::OtlpHttp { .. } + )); + assert_eq!(config.otel.trace_exporter, OtelExporterKind::None); + Ok(()) +} + #[tokio::test] async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> { let fixture = create_test_fixture()?; @@ -6506,6 +7166,28 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std: Ok(()) } +#[tokio::test] +async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + service_tier: Some(Some("fast".to_string())), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + Ok(()) +} + #[tokio::test] async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> { let fixture = create_test_fixture()?; @@ -6652,6 +7334,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6663,6 +7346,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -6808,6 +7492,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6819,6 +7504,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -6949,6 +7635,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6960,6 +7647,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -8468,68 +9156,6 @@ hide_spawn_agent_metadata = true Ok(()) } -#[tokio::test] -async fn multi_agent_v2_usage_hint_templates_use_materialized_config_values() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features.multi_agent_v2] -enabled = true -usage_hint_text = "Limit {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -root_agent_usage_hint_text = "Root {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -subagent_usage_hint_text = "Subagent {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -"#, - )?; - - let 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?; - - assert_eq!( - config.multi_agent_v2.usage_hint_text.as_deref(), - Some("Limit 4") - ); - assert_eq!( - config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), - Some("Root 4") - ); - assert_eq!( - config.multi_agent_v2.subagent_usage_hint_text.as_deref(), - Some("Subagent 4") - ); - - Ok(()) -} - -#[tokio::test] -async fn multi_agent_v2_usage_hint_templates_fail_when_placeholder_is_missing() { - let codex_home = TempDir::new().expect("create codex home"); - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features.multi_agent_v2] -enabled = true -usage_hint_text = "{{ features.multi_agent_v2.does_not_exist }}" -"#, - ) - .expect("write config"); - - let err = 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 - .expect_err("config load should fail"); - - assert!( - err.to_string() - .contains("features.multi_agent_v2.does_not_exist"), - "unexpected error: {err}", - ); -} - #[tokio::test] async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 8d4128900d..1362103364 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -3,6 +3,7 @@ use crate::path_utils::write_atomically; use anyhow::Context; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; +use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestDisabledTool; use codex_features::FEATURES; use codex_protocol::config_types::Personality; @@ -91,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit { } } +/// Produces a config edit that sets `[tui].session_picker_view = ""`. +pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "session_picker_view".to_string()], + value: value(mode.to_string()), + } +} + /// Produces a config edit that sets `[tui].status_line` to an explicit ordered list. /// /// The array is written even when it is empty so "hide the status line" stays @@ -1316,6 +1325,25 @@ impl ConfigEditsBuilder { self } + pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self { + let segments = if let Some(profile) = self.profile.as_ref() { + vec![ + "profiles".to_string(), + profile.clone(), + "tui".to_string(), + "session_picker_view".to_string(), + ] + } else { + vec!["tui".to_string(), "session_picker_view".to_string()] + }; + + self.edits.push(ConfigEdit::SetPath { + segments, + value: value(mode.to_string()), + }); + self + } + pub fn with_edits(mut self, edits: I) -> Self where I: IntoIterator, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 376632a93a..45af723b8f 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -2,6 +2,7 @@ use super::*; use codex_config::types::AppToolApproval; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_config::types::SessionPickerViewMode; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; #[cfg(unix)] @@ -48,6 +49,41 @@ fn builder_with_edits_applies_custom_paths() { assert_eq!(contents, "enabled = true\n"); } +#[test] +fn session_picker_view_edit_writes_root_tui_setting() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([session_picker_view_edit(SessionPickerViewMode::Dense)]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[tui] +session_picker_view = "dense" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn session_picker_view_builder_respects_active_profile() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_profile(Some("work")) + .set_session_picker_view(SessionPickerViewMode::Dense) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[profiles.work.tui] +session_picker_view = "dense" +"#; + assert_eq!(contents, expected); +} + #[test] fn keymap_binding_edit_writes_root_action_binding() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2154bb0170..ed355a0ae7 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,6 +49,7 @@ use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; +use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; @@ -69,7 +70,9 @@ use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; +use codex_mcp::BuiltinMcpServerOptions; use codex_mcp::McpConfig; +use codex_mcp::configured_builtin_mcp_servers; use codex_memories_read::memory_root; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; @@ -132,7 +135,6 @@ mod network_proxy_spec; mod permissions; #[cfg(test)] mod schema; -pub(crate) mod template_interpolation; pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; @@ -400,8 +402,8 @@ pub struct Config { /// Optional override of model selection. pub model: Option, - /// Effective service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, + /// Effective service tier request id preference for new turns. + pub service_tier: Option, /// Model used specifically for review sessions. pub review_model: Option, @@ -474,13 +476,15 @@ pub struct Config { pub compact_prompt: Option, /// Optional commit attribution text for commit message co-author trailers. + /// This top-level setting only takes effect when `[features].codex_git_commit` + /// is enabled. /// /// - `None`: use default attribution (`Codex `) /// - `Some("")` or whitespace-only: disable commit attribution /// - `Some("...")`: use the provided attribution text verbatim pub commit_attribution: Option, - /// Deprecated optional external notifier command. When set, Codex will spawn this + /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command /// broken into argv tokens **without** the trailing JSON argument - Codex @@ -499,7 +503,7 @@ pub struct Config { /// notify-send Codex '{"type":"agent-turn-complete","turn-id":"12345"}' /// ``` /// - /// If unset the feature is disabled. Use lifecycle hooks for new automation. + /// If unset the feature is disabled. pub notify: Option>, /// TUI notification settings, including enabled events, delivery method, and focus condition. @@ -517,6 +521,9 @@ pub struct Config { /// Start the composer in Vim mode (`Normal`) by default. pub tui_vim_mode_default: bool, + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + pub tui_raw_output_mode: bool, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -544,6 +551,9 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Preferred layout for resume/fork session picker results. + pub tui_session_picker_view: SessionPickerViewMode, + /// Terminal resize-reflow tuning knobs. pub terminal_resize_reflow: TerminalResizeReflowConfig, @@ -1081,6 +1091,13 @@ impl Config { ) -> McpConfig { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; + let builtin_mcp_servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: self.codex_self_exe.as_deref(), + codex_home: self.codex_home.as_path(), + memories_enabled: self.features.enabled(Feature::BuiltInMcp) + && self.features.enabled(Feature::MemoryTool) + && self.memories.use_memories, + }); let mut configured_mcp_servers = self.mcp_servers.get().clone(); for plugin in loaded_plugins .plugins() @@ -1100,9 +1117,12 @@ impl Config { if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() && mcp_requirements.value.is_empty() { - // A present empty allowlist bans all MCPs, including plugin MCPs merged above. + // A present empty allowlist bans configurable MCPs, including plugin MCPs merged + // above. Built-ins are product-owned and stay available regardless of admin + // allowlists. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } + configured_mcp_servers.extend(builtin_mcp_servers); McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), @@ -1123,6 +1143,62 @@ impl Config { } } + pub async fn rebuild_preserving_session_layers( + &self, + refreshed_config: &Config, + ) -> std::io::Result { + let mut layers = refreshed_config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| !is_session_layer(&layer.name)) + .cloned() + .collect::>(); + layers.extend( + self.config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| is_session_layer(&layer.name)) + .cloned(), + ); + layers.sort_by_key(|layer| layer.name.precedence()); + + let config_layer_stack = ConfigLayerStack::new( + layers, + refreshed_config.config_layer_stack.requirements().clone(), + refreshed_config + .config_layer_stack + .requirements_toml() + .clone(), + )? + .with_user_and_project_exec_policy_rules_ignored( + refreshed_config + .config_layer_stack + .ignore_user_and_project_exec_policy_rules(), + ); + let cfg: ConfigToml = config_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + Self::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + cfg, + ConfigOverrides { + cwd: Some(self.cwd.to_path_buf()), + ..Default::default() + }, + refreshed_config.codex_home.clone(), + config_layer_stack, + ) + .await + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1673,6 +1749,10 @@ fn thread_store_config( } } +fn is_session_layer(source: &ConfigLayerSource) -> bool { + matches!(source, ConfigLayerSource::SessionFlags) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, @@ -1789,7 +1869,7 @@ pub struct ConfigOverrides { pub permission_profile: Option, pub default_permissions: Option, pub model_provider: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, @@ -2016,63 +2096,6 @@ impl Config { overrides: ConfigOverrides, codex_home: AbsolutePathBuf, config_layer_stack: ConfigLayerStack, - ) -> std::io::Result { - let config = Self::build_config_with_layer_stack( - fs, - cfg.clone(), - overrides.clone(), - codex_home.clone(), - config_layer_stack.clone(), - ) - .await?; - let mut interpolation_source_cfg = cfg.clone(); - template_interpolation::apply_resolved_config_fields( - &config, - &mut interpolation_source_cfg, - ) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to materialize config for interpolation: {err}"), - ) - })?; - let interpolation_source = - toml::Value::try_from(interpolation_source_cfg).map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to serialize config for interpolation: {err}"), - ) - })?; - let mut interpolated_cfg = cfg; - let interpolated = template_interpolation::interpolate_config_string_fields( - &mut interpolated_cfg, - &interpolation_source, - ) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to interpolate config template fields: {err}"), - ) - })?; - if interpolated { - return Self::build_config_with_layer_stack( - fs, - interpolated_cfg, - overrides, - codex_home, - config_layer_stack, - ) - .await; - } - Ok(config) - } - - async fn build_config_with_layer_stack( - fs: &dyn ExecutorFileSystem, - cfg: ConfigToml, - overrides: ConfigOverrides, - codex_home: AbsolutePathBuf, - config_layer_stack: ConfigLayerStack, ) -> std::io::Result { // Keep the large config-construction future off small test thread stacks. Box::pin(async move { @@ -2714,16 +2737,20 @@ impl Config { notices.fast_default_opt_out = Some(true); None } - None => config_profile.service_tier.or(cfg.service_tier), + None => config_profile + .service_tier + .or(cfg.service_tier) + .map(|service_tier| service_tier.request_value().to_string()), }; - let service_tier = match service_tier { - Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { - Some(ServiceTier::Fast) + let service_tier = service_tier.and_then(|service_tier| { + match ServiceTier::from_request_value(&service_tier) { + Some(ServiceTier::Fast) => features + .enabled(Feature::FastMode) + .then(|| ServiceTier::Fast.request_value().to_string()), + Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()), + None => Some(service_tier), } - Some(ServiceTier::Fast) => None, - Some(ServiceTier::Flex) => Some(ServiceTier::Flex), - None => None, - }; + }); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -3147,6 +3174,11 @@ impl Config { .as_ref() .map(|t| t.vim_mode_default) .unwrap_or(false), + tui_raw_output_mode: cfg + .tui + .as_ref() + .map(|t| t.raw_output_mode) + .unwrap_or(false), tui_alternate_screen: cfg .tui .as_ref() @@ -3160,6 +3192,12 @@ impl Config { .unwrap_or(true), tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + tui_session_picker_view: config_profile + .tui + .as_ref() + .and_then(|t| t.session_picker_view) + .or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view)) + .unwrap_or_default(), terminal_resize_reflow, tui_keymap: cfg .tui @@ -3173,7 +3211,10 @@ impl Config { .environment .unwrap_or(DEFAULT_OTEL_ENVIRONMENT.to_string()); let exporter = t.exporter.unwrap_or(OtelExporterKind::None); - let trace_exporter = t.trace_exporter.unwrap_or_else(|| exporter.clone()); + // OTLP HTTP endpoints are signal-specific in our config, so + // enabling log export must not implicitly send spans to a + // /v1/logs endpoint. + let trace_exporter = t.trace_exporter.unwrap_or(OtelExporterKind::None); let metrics_exporter = t.metrics_exporter.unwrap_or(OtelExporterKind::Statsig); OtelConfig { log_user_prompt, diff --git a/codex-rs/core/src/config/template_interpolation.rs b/codex-rs/core/src/config/template_interpolation.rs deleted file mode 100644 index 7e38fed86f..0000000000 --- a/codex-rs/core/src/config/template_interpolation.rs +++ /dev/null @@ -1,189 +0,0 @@ -use anyhow::Context; -use anyhow::bail; -use codex_config::config_toml::ConfigToml; -use codex_config::types::MemoriesToml; -use codex_features::AppsMcpPathOverrideConfigToml; -use codex_features::Feature; -use codex_features::FeatureToml; -use codex_features::FeaturesToml; -use codex_features::MultiAgentV2ConfigToml; -use codex_utils_template::Template; -use toml::Value as TomlValue; - -use super::Config; - -const INTERPOLATED_CONFIG_STRING_FIELDS: &[&str] = &[ - "features.multi_agent_v2.usage_hint_text", - "features.multi_agent_v2.root_agent_usage_hint_text", - "features.multi_agent_v2.subagent_usage_hint_text", -]; - -pub(crate) fn materialized_config_toml(config: &Config) -> anyhow::Result { - let mut materialized: ConfigToml = config - .config_layer_stack - .effective_config() - .try_into() - .context("failed to deserialize effective config for config interpolation")?; - apply_resolved_config_fields(config, &mut materialized)?; - Ok(materialized) -} - -pub(crate) fn interpolate_config_string_fields( - config_toml: &mut ConfigToml, - interpolation_source: &TomlValue, -) -> anyhow::Result { - let mut target_value = TomlValue::try_from(config_toml.clone()) - .context("failed to serialize config for interpolation")?; - let mut changed = false; - - for field_path in INTERPOLATED_CONFIG_STRING_FIELDS { - let Some(value) = value_mut_at_path(&mut target_value, field_path) else { - continue; - }; - let Some(template_source) = value.as_str() else { - bail!("interpolated config field `{field_path}` must be a string"); - }; - let template = Template::parse(template_source) - .with_context(|| format!("failed to parse template in config field `{field_path}`"))?; - let rendered = render_template(&template, interpolation_source, field_path)?; - if rendered != template_source { - *value = TomlValue::String(rendered); - changed = true; - } - } - - if changed { - *config_toml = target_value - .try_into() - .context("failed to deserialize interpolated config")?; - } - - Ok(changed) -} - -pub(crate) fn apply_resolved_config_fields( - config: &Config, - config_toml: &mut ConfigToml, -) -> anyhow::Result<()> { - config_toml.web_search = Some(config.web_search_mode.value()); - config_toml.model_provider = Some(config.model_provider_id.clone()); - config_toml.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; - config_toml.model_verbosity = config.model_verbosity; - config_toml.include_permissions_instructions = Some(config.include_permissions_instructions); - config_toml.include_apps_instructions = Some(config.include_apps_instructions); - config_toml.include_environment_context = Some(config.include_environment_context); - config_toml.background_terminal_max_timeout = Some(config.background_terminal_max_timeout); - - // Feature aliases and feature configs need to be written in their resolved - // form; otherwise replay can drift when a legacy key maps to the same - // runtime feature. - let features = config_toml - .features - .get_or_insert_with(FeaturesToml::default); - features.materialize_resolved_enabled(config.features.get()); - let mut multi_agent_v2: MultiAgentV2ConfigToml = - resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; - multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); - features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); - features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml { - enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)), - path: config.apps_mcp_path_override.clone(), - })); - - config_toml.memories = Some(resolved_config_to_toml::( - &config.memories, - "memories", - )?); - - let agents = config_toml.agents.get_or_insert_with(Default::default); - // Multi-agent v2 owns thread fanout through its feature config. Preserve - // the legacy agents.max_threads setting only when v2 is disabled. - agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) { - None - } else { - config.agent_max_threads - }; - agents.max_depth = Some(config.agent_max_depth); - agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; - agents.interrupt_message = Some(config.agent_interrupt_message_enabled); - - config_toml - .skills - .get_or_insert_with(Default::default) - .include_instructions = Some(config.include_skill_instructions); - - Ok(()) -} - -fn render_template( - template: &Template, - interpolation_source: &TomlValue, - field_path: &str, -) -> anyhow::Result { - let variables = - template - .placeholders() - .map(|placeholder| { - let value = lookup_scalar_path(interpolation_source, placeholder).with_context(|| { - format!("failed to render config field `{field_path}` placeholder `{placeholder}`") - })?; - Ok((placeholder.to_string(), value)) - }) - .collect::>>()?; - - template - .render( - variables - .iter() - .map(|(name, value)| (name.as_str(), value.as_str())), - ) - .with_context(|| format!("failed to render config field `{field_path}`")) -} - -fn lookup_scalar_path(value: &TomlValue, path: &str) -> anyhow::Result { - let resolved = value_at_path(value, path) - .with_context(|| format!("template placeholder `{path}` does not exist"))?; - - match resolved { - TomlValue::String(value) => Ok(value.clone()), - TomlValue::Integer(value) => Ok(value.to_string()), - TomlValue::Float(value) => Ok(value.to_string()), - TomlValue::Boolean(value) => Ok(value.to_string()), - _ => bail!( - "template placeholder `{path}` must resolve to a scalar string, integer, float, or boolean" - ), - } -} - -fn value_at_path<'a>(value: &'a TomlValue, path: &str) -> Option<&'a TomlValue> { - let mut current = value; - for segment in path.split('.') { - current = current.as_table()?.get(segment)?; - } - Some(current) -} - -fn value_mut_at_path<'a>(value: &'a mut TomlValue, path: &str) -> Option<&'a mut TomlValue> { - let mut current = value; - let mut segments = path.split('.').peekable(); - - while let Some(segment) = segments.next() { - let table = current.as_table_mut()?; - if segments.peek().is_none() { - return table.get_mut(segment); - } - current = table.get_mut(segment)?; - } - - Some(current) -} - -fn resolved_config_to_toml( - value: &impl serde::Serialize, - label: &'static str, -) -> anyhow::Result -where - Toml: serde::de::DeserializeOwned + serde::Serialize, -{ - crate::config_lock::toml_round_trip(value, label).map_err(anyhow::Error::from) -} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 9f0381f53a..0bd53a50ee 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -45,6 +45,7 @@ use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; +use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -246,6 +247,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await; let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -278,8 +280,10 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), + host_owned_codex_apps_enabled, ToolPluginProvenance::default(), auth.as_ref(), + /*elicitation_reviewer*/ None, ) .await; diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index c647550ee2..ca1ac5f2fb 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,4 +1,6 @@ use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; +use crate::shell::Shell; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_utils_absolute_path::AbsolutePathBuf; @@ -30,15 +32,16 @@ impl EnvironmentContextEnvironment { } } - fn from_turn_environments( - environments: &[crate::session::turn_context::TurnEnvironment], - ) -> Vec { + fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { environments .iter() .map(|environment| Self { id: environment.environment_id.clone(), cwd: environment.cwd.clone(), - shell: environment.shell.clone(), + shell: environment + .shell + .clone() + .unwrap_or_else(|| shell.name().to_string()), }) .collect() } @@ -174,10 +177,11 @@ impl EnvironmentContext { ) } - pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self { + pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( EnvironmentContextEnvironment::from_turn_environments( &turn_context.environments.turn_environments, + shell, ), turn_context.current_date.clone(), turn_context.timezone.clone(), diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 24ff4bbfff..bc0a17ca5d 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -259,3 +259,46 @@ fn serialize_environment_context_with_multiple_selected_environments() { assert_eq!(context.render(), expected); } + +#[test] +fn serialize_environment_context_prefers_environment_shell_when_present() { + let local_cwd = test_path_buf("/repo/local"); + let remote_cwd = test_path_buf("/repo/remote"); + let context = EnvironmentContext::new( + vec![ + EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: local_cwd.abs(), + shell: "powershell".to_string(), + }, + EnvironmentContextEnvironment { + id: "remote".to_string(), + cwd: remote_cwd.abs(), + shell: "cmd".to_string(), + }, + ], + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ); + + let expected = format!( + r#" + + + {} + powershell + + + {} + cmd + + +"#, + local_cwd.display(), + remote_cwd.display() + ); + + assert_eq!(context.render(), expected); +} diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index db7850008f..1bc2cb0895 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -29,7 +29,7 @@ fn build_environment_update_item( let prev = previous?; let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string()); - let next_context = EnvironmentContext::from_turn_context(next); + let next_context = EnvironmentContext::from_turn_context(next, shell); if prev_context.equals_except_shell(&next_context) { return None; } diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index e9d617cbf4..b4bd9cbe89 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -75,9 +75,7 @@ pub(crate) fn resolve_environment_selections( environment_id, environment, cwd: selected_environment.cwd.clone(), - // TODO(starr): Resolve shell metadata per environment instead of - // hardcoding bash. - shell: "bash".to_string(), + shell: None, }); } @@ -176,5 +174,6 @@ mod tests { .environment_id, "local" ); + assert_eq!(resolved.primary().expect("primary environment").shell, None); } } diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 4165c05146..4af73e6a01 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -12,6 +12,11 @@ use crate::state::TurnState; use crate::tasks::RegularTask; use anyhow::Context; use codex_features::Feature; +use codex_otel::GOAL_BUDGET_LIMITED_METRIC; +use codex_otel::GOAL_COMPLETED_METRIC; +use codex_otel::GOAL_CREATED_METRIC; +use codex_otel::GOAL_DURATION_SECONDS_METRIC; +use codex_otel::GOAL_TOKEN_COUNT_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -24,7 +29,6 @@ use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::validate_thread_goal_objective; use codex_rollout::state_db::reconcile_rollout; -use codex_thread_store::LocalThreadStore; use codex_utils_template::Template; use futures::future::BoxFuture; use std::sync::Arc; @@ -68,6 +72,27 @@ enum BudgetLimitSteering { Suppressed, } +#[derive(Clone, Copy)] +enum TerminalMetricEmission { + Emit, + Suppress, +} + +/// Describes whether an external goal mutation created a new logical goal or +/// updated an existing one. +#[derive(Clone, Copy)] +pub enum ExternalGoalPreviousStatus { + NewGoal, + Existing(codex_state::ThreadGoalStatus), +} + +/// Runtime effects for an externally persisted goal mutation. +#[derive(Clone)] +pub struct ExternalGoalSet { + pub goal: codex_state::ThreadGoal, + pub previous_status: ExternalGoalPreviousStatus, +} + /// Runtime lifecycle events that can affect goal accounting, scheduling, or /// model-visible steering. /// @@ -96,7 +121,7 @@ pub(crate) enum GoalRuntimeEvent<'a> { }, ExternalMutationStarting, ExternalSet { - status: codex_state::ThreadGoalStatus, + external_set: ExternalGoalSet, }, ExternalClear, ThreadResumed, @@ -293,14 +318,22 @@ impl Session { tool_name, } => Box::pin(async move { if tool_name != codex_tools::UPDATE_GOAL_TOOL_NAME { - self.account_thread_goal_progress(turn_context, BudgetLimitSteering::Allowed) - .await?; + self.account_thread_goal_progress( + turn_context, + BudgetLimitSteering::Allowed, + TerminalMetricEmission::Emit, + ) + .await?; } Ok(()) }), GoalRuntimeEvent::ToolCompletedGoal { turn_context } => Box::pin(async move { - self.account_thread_goal_progress(turn_context, BudgetLimitSteering::Suppressed) - .await?; + self.account_thread_goal_progress( + turn_context, + BudgetLimitSteering::Suppressed, + TerminalMetricEmission::Suppress, + ) + .await?; Ok(()) }), GoalRuntimeEvent::TurnFinished { @@ -331,8 +364,8 @@ impl Session { } Ok(()) }), - GoalRuntimeEvent::ExternalSet { status } => Box::pin(async move { - self.apply_external_thread_goal_status(status).await; + GoalRuntimeEvent::ExternalSet { external_set } => Box::pin(async move { + self.apply_external_thread_goal_status(external_set).await; Ok(()) }), GoalRuntimeEvent::ExternalClear => Box::pin(async move { @@ -384,6 +417,7 @@ impl Session { self.account_thread_goal_wall_clock_usage( &state_db, codex_state::ThreadGoalAccountingMode::ActiveOnly, + TerminalMetricEmission::Emit, ) .await?; let mut replacing_goal = objective.is_some(); @@ -454,6 +488,15 @@ impl Session { let goal_status = goal.status; let goal_id = goal.goal_id.clone(); + let previous_status_for_goal = if replacing_goal { + None + } else { + previous_status + }; + if replacing_goal { + self.emit_goal_created_metric(); + } + self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal); let goal = protocol_goal_from_state(goal); *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; let newly_active_goal = goal_status == codex_state::ThreadGoalStatus::Active @@ -504,6 +547,7 @@ impl Session { self.account_thread_goal_wall_clock_usage( &state_db, codex_state::ThreadGoalAccountingMode::ActiveOnly, + TerminalMetricEmission::Emit, ) .await?; let goal = state_db @@ -522,6 +566,7 @@ impl Session { })?; let goal_id = goal.goal_id.clone(); + self.emit_goal_created_metric(); let goal = protocol_goal_from_state(goal); *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; @@ -545,44 +590,30 @@ impl Session { Ok(goal) } - async fn apply_external_thread_goal_status( - self: &Arc, - status: codex_state::ThreadGoalStatus, - ) { + async fn apply_external_thread_goal_status(self: &Arc, external_set: ExternalGoalSet) { + let ExternalGoalSet { + goal, + previous_status, + } = external_set; + let previous_status = match previous_status { + ExternalGoalPreviousStatus::NewGoal => { + self.emit_goal_created_metric(); + None + } + ExternalGoalPreviousStatus::Existing(status) => Some(status), + }; + self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); + let goal_id = goal.goal_id; + let status = goal.status; match status { codex_state::ThreadGoalStatus::Active => { - match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => { - match state_db.get_thread_goal(self.conversation_id).await { - Ok(Some(goal)) - if goal.status == codex_state::ThreadGoalStatus::Active => - { - let turn_id = self - .active_turn_context() - .await - .map(|turn_context| turn_context.sub_id.clone()); - let current_token_usage = - self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting( - goal.goal_id, - turn_id, - current_token_usage, - ) - .await; - } - Ok(Some(_)) | Ok(None) => {} - Err(err) => { - tracing::warn!( - "failed to read active goal after external set: {err}" - ); - } - } - } - Err(err) => { - tracing::warn!("failed to open state db after external goal set: {err}"); - } - Ok(None) => {} - } + let turn_id = self + .active_turn_context() + .await + .map(|turn_context| turn_context.sub_id.clone()); + let current_token_usage = self.total_token_usage().await.unwrap_or_default(); + self.mark_active_goal_accounting(goal_id, turn_id, current_token_usage) + .await; self.maybe_continue_goal_if_idle_runtime().await; } codex_state::ThreadGoalStatus::BudgetLimited => { @@ -638,6 +669,57 @@ impl Session { accounting.wall_clock.mark_active_goal(goal_id); } + fn emit_goal_created_metric(&self) { + self.services + .session_telemetry + .counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]); + } + + fn emit_goal_terminal_metrics_if_status_changed( + &self, + previous_status: Option, + goal: &codex_state::ThreadGoal, + ) { + if previous_status == Some(goal.status) { + return; + } + + let counter = match goal.status { + codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC, + codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC, + codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => { + return; + } + }; + let status_tag = [("status", goal.status.as_str())]; + self.services + .session_telemetry + .counter(counter, /*inc*/ 1, &[]); + self.services.session_telemetry.histogram( + GOAL_TOKEN_COUNT_METRIC, + goal.tokens_used, + &status_tag, + ); + self.services.session_telemetry.histogram( + GOAL_DURATION_SECONDS_METRIC, + goal.time_used_seconds, + &status_tag, + ); + } + + async fn current_goal_status_for_metrics( + &self, + state_db: &StateDbHandle, + expected_goal_id: Option<&str>, + ) -> anyhow::Result> { + let goal = state_db.get_thread_goal(self.conversation_id).await?; + Ok(goal.and_then(|goal| { + expected_goal_id + .is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id) + .then_some(goal.status) + })) + } + async fn active_turn_context(&self) -> Option> { let active = self.active_turn.lock().await; active @@ -732,7 +814,11 @@ impl Session { ) { if turn_completed && let Err(err) = self - .account_thread_goal_progress(turn_context, BudgetLimitSteering::Suppressed) + .account_thread_goal_progress( + turn_context, + BudgetLimitSteering::Suppressed, + TerminalMetricEmission::Emit, + ) .await { tracing::warn!("failed to account thread goal progress at turn end: {err}"); @@ -761,7 +847,11 @@ impl Session { self.take_thread_goal_continuation_turn(&turn_context.sub_id) .await; if let Err(err) = self - .account_thread_goal_progress(turn_context, BudgetLimitSteering::Suppressed) + .account_thread_goal_progress( + turn_context, + BudgetLimitSteering::Suppressed, + TerminalMetricEmission::Emit, + ) .await { tracing::warn!("failed to account thread goal progress after abort: {err}"); @@ -787,6 +877,7 @@ impl Session { &self, turn_context: &TurnContext, budget_limit_steering: BudgetLimitSteering, + terminal_metric_emission: TerminalMetricEmission, ) -> anyhow::Result<()> { if !self.enabled(Feature::Goals) { return Ok(()); @@ -820,6 +911,9 @@ impl Session { if time_delta_seconds == 0 && token_delta <= 0 { return Ok(()); } + let previous_status = self + .current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref()) + .await?; let outcome = state_db .account_thread_goal_usage( self.conversation_id, @@ -862,6 +956,9 @@ impl Session { accounting.wall_clock.clear_active_goal(); } } + if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { + self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); + } goal } codex_state::ThreadGoalAccountingOutcome::Unchanged(_) => return Ok(()), @@ -901,6 +998,7 @@ impl Session { .account_thread_goal_progress( turn_context.as_ref(), BudgetLimitSteering::Suppressed, + TerminalMetricEmission::Emit, ) .await; } @@ -911,6 +1009,7 @@ impl Session { self.account_thread_goal_wall_clock_usage( &state_db, codex_state::ThreadGoalAccountingMode::ActiveOnly, + TerminalMetricEmission::Suppress, ) .await?; Ok(()) @@ -920,6 +1019,7 @@ impl Session { &self, state_db: &StateDbHandle, mode: codex_state::ThreadGoalAccountingMode, + terminal_metric_emission: TerminalMetricEmission, ) -> anyhow::Result> { let _accounting_permit = self.goal_runtime.accounting_permit().await?; let (time_delta_seconds, expected_goal_id) = { @@ -932,6 +1032,9 @@ impl Session { if time_delta_seconds == 0 { return Ok(None); } + let previous_status = self + .current_goal_status_for_metrics(state_db, expected_goal_id.as_deref()) + .await?; match state_db .account_thread_goal_usage( @@ -944,6 +1047,9 @@ impl Session { .await? { codex_state::ThreadGoalAccountingOutcome::Updated(goal) => { + if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { + self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); + } self.goal_runtime .accounting .lock() @@ -989,6 +1095,7 @@ impl Session { self.account_thread_goal_wall_clock_usage( &state_db, codex_state::ThreadGoalAccountingMode::ActiveStatusOnly, + TerminalMetricEmission::Emit, ) .await?; let Some(goal) = state_db @@ -1230,17 +1337,6 @@ impl Session { state_db } else if let Some(state_db) = self.goal_runtime.state_db.lock().await.clone() { state_db - } else if let Some(local_store) = self - .services - .thread_store - .as_any() - .downcast_ref::() - { - local_store.state_db().await.ok_or_else(|| { - anyhow::anyhow!( - "thread goals require a local persisted thread with a state database" - ) - })? } else { anyhow::bail!("thread goals require a local persisted thread with a state database"); }; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 6fd50219d8..a419d7cbfa 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -35,7 +35,6 @@ use crate::config::NetworkProxySpec; use crate::config::Permissions; use crate::context::ContextualUserFragment; use crate::context::GuardianFollowupReviewReminder; -use crate::rollout::recorder::RolloutRecorder; use crate::session::Codex; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -774,12 +773,11 @@ async fn append_guardian_followup_reminder(review_session: &GuardianReviewSessio async fn load_rollout_items_for_fork( session: &Session, ) -> anyhow::Result>> { + session.try_ensure_rollout_materialized().await?; session.flush_rollout().await?; - let Some(rollout_path) = session.current_rollout_path().await? else { - return Ok(None); - }; - let history = RolloutRecorder::get_rollout_history(rollout_path.as_path()).await?; - Ok(Some(history.get_rollout_items())) + let live_thread = session.live_thread_for_persistence("guardian review fork")?; + let history = live_thread.load_history(/*include_archived*/ true).await?; + Ok(Some(history.items)) } async fn wait_for_guardian_review( diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 78362b6f89..6d03dfa813 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1894,7 +1894,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> #[tokio::test] async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> { - const TEST_STACK_SIZE_BYTES: usize = 2 * 1024 * 1024; + const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; let handle = std::thread::Builder::new() diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 9a92854515..01d5ce1534 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -161,8 +161,10 @@ pub(crate) async fn run_pre_tool_use_hooks( hook_events, should_block, block_reason, + additional_contexts, } = hooks.run_pre_tool_use(request).await; emit_hook_completed_events(sess, turn_context, hook_events).await; + record_additional_contexts(sess, turn_context, additional_contexts).await; if should_block { block_reason.map(|reason| { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index cf909c5e81..1a754d1ece 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -39,6 +39,8 @@ mod flags; #[cfg(test)] mod git_info_tests; mod goals; +pub use goals::ExternalGoalPreviousStatus; +pub use goals::ExternalGoalSet; mod guardian; mod hook_runtime; mod installation_id; @@ -59,14 +61,9 @@ pub use codex_mcp::SandboxState; mod mcp_openai_file; mod mcp_tool_call; pub(crate) mod mention_syntax; -pub(crate) mod message_history; pub(crate) mod utils; pub use mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; pub use mention_syntax::TOOL_MENTION_SIGIL; -pub use message_history::HistoryEntry as MessageHistoryEntry; -pub use message_history::append_entry as append_message_history_entry; -pub use message_history::history_metadata as message_history_metadata; -pub use message_history::lookup as lookup_message_history_entry; pub use utils::path_utils; pub mod personality_migration; pub(crate) mod plugins; @@ -87,11 +84,9 @@ mod session_prefix; mod session_startup_prewarm; mod shell_detect; pub mod skills; -pub(crate) use skills::SkillError; pub(crate) use skills::SkillInjections; pub(crate) use skills::SkillLoadOutcome; pub(crate) use skills::SkillMetadata; -pub(crate) use skills::SkillsLoadInput; pub(crate) use skills::SkillsManager; pub(crate) use skills::build_available_skills; pub(crate) use skills::build_skill_injections; @@ -122,7 +117,9 @@ pub use thread_manager::NewThread; pub use thread_manager::StartThreadOptions; pub use thread_manager::ThreadManager; pub use thread_manager::ThreadShutdownReport; +pub use thread_manager::agent_graph_store_from_state_db; pub use thread_manager::build_models_manager; +pub use thread_manager::init_state_db_from_config; pub use thread_manager::thread_store_from_config; pub use web_search::web_search_action_detail; pub use web_search::web_search_detail; diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index c24a6f3a48..aefc783cb6 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -19,6 +19,7 @@ use crate::SkillMetadata; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::skills::model::SkillToolDependency; +use codex_mcp::ElicitationReviewerHandle; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::McpPermissionPromptAutoApproveContext; use codex_mcp::mcp_permission_prompt_is_auto_approved; @@ -35,6 +36,7 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies( turn_context: &TurnContext, cancellation_token: &CancellationToken, mentioned_skills: &[SkillMetadata], + elicitation_reviewer: Option, ) { let originator_value = originator().value; if !is_first_party_originator(originator_value.as_str()) { @@ -69,7 +71,14 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies( if should_install_mcp_dependencies(sess, turn_context, &unprompted_missing, cancellation_token) .await { - maybe_install_mcp_dependencies(sess, turn_context, config.as_ref(), mentioned_skills).await; + maybe_install_mcp_dependencies( + sess, + turn_context, + config.as_ref(), + mentioned_skills, + elicitation_reviewer, + ) + .await; } } @@ -78,6 +87,7 @@ pub(crate) async fn maybe_install_mcp_dependencies( turn_context: &TurnContext, config: &crate::config::Config, mentioned_skills: &[SkillMetadata], + elicitation_reviewer: Option, ) { if mentioned_skills.is_empty() || !config @@ -194,6 +204,7 @@ pub(crate) async fn maybe_install_mcp_dependencies( turn_context, refresh_servers, config.mcp_oauth_credentials_store_mode, + elicitation_reviewer, ) .await; } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 58f26cb25e..c7e665cb02 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -33,6 +33,7 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::hook_names::HookToolName; use crate::tools::sandboxing::PermissionRequestPayload; +use crate::turn_metadata::McpTurnMetadataContext; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::build_track_events_context; @@ -40,8 +41,11 @@ use codex_config::types::AppToolApproval; use codex_features::Feature; use codex_hooks::PermissionRequestDecision; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; use codex_mcp::McpPermissionPromptAutoApproveContext; use codex_mcp::SandboxState; +use codex_mcp::auth_elicitation_completed_result; +use codex_mcp::build_auth_elicitation_plan; use codex_mcp::declared_openai_file_input_param_names; use codex_mcp::mcp_permission_prompt_is_auto_approved; use codex_otel::sanitize_metric_tag_value; @@ -50,7 +54,22 @@ use codex_protocol::items::McpToolCallItem; use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::TurnItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_TOOL_APPROVAL_KIND_KEY; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY; +use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY; +use codex_protocol::mcp_approval_meta::CONNECTOR_NAME_KEY as MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY; +use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as MCP_TOOL_APPROVAL_PERSIST_ALWAYS; +use codex_protocol::mcp_approval_meta::PERSIST_KEY as MCP_TOOL_APPROVAL_PERSIST_KEY; +use codex_protocol::mcp_approval_meta::PERSIST_SESSION as MCP_TOOL_APPROVAL_PERSIST_SESSION; +use codex_protocol::mcp_approval_meta::SOURCE_CONNECTOR as MCP_TOOL_APPROVAL_SOURCE_CONNECTOR; +use codex_protocol::mcp_approval_meta::SOURCE_KEY as MCP_TOOL_APPROVAL_SOURCE_KEY; +use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY; +use codex_protocol::mcp_approval_meta::TOOL_PARAMS_DISPLAY_KEY as MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY; +use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY; +use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_TOOL_APPROVAL_TOOL_TITLE_KEY; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -339,9 +358,10 @@ async fn handle_approved_mcp_tool_call( let result = execute_mcp_tool_call( sess, turn_context, - &server, - &tool_name, + call_id, + &invocation, rewritten_arguments, + metadata, request_meta, ) .await; @@ -540,28 +560,145 @@ fn truncate_str_to_char_boundary(value: &str, max_chars: usize) -> &str { async fn execute_mcp_tool_call( sess: &Session, turn_context: &TurnContext, - server: &str, - tool_name: &str, + call_id: &str, + invocation: &McpInvocation, rewritten_arguments: Option, + metadata: Option<&McpToolApprovalMetadata>, request_meta: Option, ) -> Result { let request_meta = with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string()); - let request_meta = - augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta) - .await - .map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?; + let request_meta = augment_mcp_tool_request_meta_with_sandbox_state( + sess, + turn_context, + &invocation.server, + request_meta, + ) + .await + .map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?; let result = sess - .call_tool(server, tool_name, rewritten_arguments, request_meta) + .call_tool( + &invocation.server, + &invocation.tool, + rewritten_arguments, + request_meta, + ) .await .map_err(|e| format!("tool call error: {e:?}"))?; - sanitize_mcp_tool_result_for_model( + let result = sanitize_mcp_tool_result_for_model( turn_context .model_info .input_modalities .contains(&InputModality::Image), Ok(result), + )?; + Ok(maybe_request_codex_apps_auth_elicitation( + sess, + turn_context, + call_id, + &invocation.server, + metadata, + result, ) + .await) +} + +async fn maybe_request_codex_apps_auth_elicitation( + sess: &Session, + turn_context: &TurnContext, + call_id: &str, + server: &str, + metadata: Option<&McpToolApprovalMetadata>, + result: CallToolResult, +) -> CallToolResult { + if !sess + .services + .mcp_connection_manager + .read() + .await + .is_host_owned_codex_apps_server(server) + { + return result; + } + + if !turn_context.features.enabled(Feature::AuthElicitation) { + return result; + } + + match turn_context.approval_policy.value() { + AskForApproval::Never => return result, + AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => { + return result; + } + AskForApproval::OnFailure + | AskForApproval::OnRequest + | AskForApproval::UnlessTrusted + | AskForApproval::Granular(_) => {} + } + + let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref()); + let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref()); + let install_url = connector_id.map(|connector_id| { + codex_connectors::metadata::connector_install_url( + connector_name.unwrap_or(connector_id), + connector_id, + ) + }); + let Some(plan) = + build_auth_elicitation_plan(call_id, &result, connector_id, connector_name, install_url) + else { + return result; + }; + + let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into()); + let params = McpServerElicitationRequestParams { + thread_id: sess.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: Some(plan.elicitation.meta), + message: plan.elicitation.message, + url: plan.elicitation.url, + elicitation_id: plan.elicitation.elicitation_id, + }, + }; + let response = sess + .request_mcp_server_elicitation(turn_context, request_id, params) + .await; + if !response + .as_ref() + .is_some_and(|response| response.action == ElicitationAction::Accept) + { + return result; + } + + refresh_codex_apps_after_connector_auth(sess, turn_context).await; + auth_elicitation_completed_result(&plan.auth_failure, result.meta) +} + +#[expect( + clippy::await_holding_invalid_type, + reason = "Codex Apps cache refresh reads through the session-owned manager guard" +)] +async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &TurnContext) { + let mcp_tools_result = { + let manager = sess.services.mcp_connection_manager.read().await; + manager.hard_refresh_codex_apps_tools_cache().await + }; + + match mcp_tools_result { + Ok(mcp_tools) => { + let auth = sess.services.auth_manager.auth().await; + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn_context.config, + auth.as_ref(), + &mcp_tools, + ); + } + Err(err) => { + tracing::warn!("failed to refresh Codex Apps tools after connector auth: {err:#}"); + } + } } #[expect( @@ -833,7 +970,6 @@ pub(crate) struct McpToolApprovalMetadata { openai_file_input_params: Option>, } -const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; @@ -895,7 +1031,13 @@ fn build_mcp_tool_call_request_meta( ) -> Option { let mut request_meta = serde_json::Map::new(); - if let Some(turn_metadata) = turn_context.turn_metadata_state.current_meta_value() { + if let Some(turn_metadata) = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(McpTurnMetadataContext { + model: turn_context.model_info.slug.as_str(), + reasoning_effort: turn_context.effective_reasoning_effort(), + }) + { request_meta.insert( crate::X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata, @@ -969,20 +1111,6 @@ pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this se pub(crate) const MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC: &str = "__codex_mcp_decline__"; const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again"; const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel"; -const MCP_TOOL_APPROVAL_KIND_KEY: &str = "codex_approval_kind"; -const MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; -const MCP_TOOL_APPROVAL_PERSIST_KEY: &str = "persist"; -const MCP_TOOL_APPROVAL_PERSIST_SESSION: &str = "session"; -const MCP_TOOL_APPROVAL_PERSIST_ALWAYS: &str = "always"; -const MCP_TOOL_APPROVAL_SOURCE_KEY: &str = "source"; -const MCP_TOOL_APPROVAL_SOURCE_CONNECTOR: &str = "connector"; -const MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY: &str = "connector_id"; -const MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY: &str = "connector_name"; -const MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: &str = "connector_description"; -const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title"; -const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description"; -const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; -const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default"; const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow"; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index d83307e808..a556e228ac 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1,9 +1,11 @@ use super::*; use crate::config::ConfigBuilder; +use crate::config::ManagedFeatures; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; use crate::state::ActiveTurn; use crate::test_support::models_manager_with_provider; +use crate::turn_metadata::McpTurnMetadataContext; use codex_config::CONFIG_TOML_FILE; use codex_config::config_toml::ConfigToml; use codex_config::types::AppConfig; @@ -13,13 +15,16 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsConfigToml; use codex_config::types::McpServerConfig; use codex_config::types::McpServerToolConfig; +use codex_features::Features; use codex_hooks::Hooks; use codex_hooks::HooksConfig; use codex_model_provider::create_model_provider; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; use core_test_support::PathExt; +use core_test_support::hooks::trusted_config_layer_stack; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -70,6 +75,13 @@ fn approval_metadata( } } +fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataContext<'_> { + McpTurnMetadataContext { + model: turn_context.model_info.slug.as_str(), + reasoning_effort: turn_context.effective_reasoning_effort(), + } +} + fn write_sample_plugin_mcp(codex_home: &std::path::Path) { let plugin_root = codex_home.join("plugins/cache/test/sample/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); @@ -163,13 +175,24 @@ print({hook_output:?}) .to_string(), ) .expect("write hooks.json"); + let hook_list = codex_hooks::list_hooks(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + ..HooksConfig::default() + }); + assert_eq!(hook_list.hooks.len(), 1); + let trusted_config_layer_stack = trusted_config_layer_stack( + &turn_context.config.config_layer_stack, + &turn_context.config.codex_home, + hook_list.hooks, + ); session .services .hooks .store(Arc::new(Hooks::new(HooksConfig { feature_enabled: true, - config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + config_layer_stack: Some(trusted_config_layer_stack), shell_program: (!cfg!(windows)).then_some("/bin/sh".to_string()), shell_args: if cfg!(windows) { Vec::new() @@ -908,13 +931,10 @@ fn truncate_mcp_tool_result_for_event_bounds_large_error() { #[tokio::test] async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = serde_json::from_str::( - &turn_context - .turn_metadata_state - .current_header_value() - .expect("turn metadata header"), - ) - .expect("turn metadata json"); + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); let meta = build_mcp_tool_call_request_meta( &turn_context, @@ -923,6 +943,25 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { /*metadata*/ None, ) .expect("custom servers should receive turn metadata"); + let turn_metadata = meta + .get(crate::X_CODEX_TURN_METADATA_HEADER) + .expect("turn metadata should be present"); + + assert_eq!( + turn_metadata + .get("model") + .and_then(serde_json::Value::as_str), + Some(turn_context.model_info.slug.as_str()) + ); + assert_eq!( + turn_metadata + .get("reasoning_effort") + .and_then(serde_json::Value::as_str), + turn_context + .effective_reasoning_effort() + .map(|effort| effort.to_string()) + .as_deref() + ); assert_eq!( meta, @@ -961,13 +1000,10 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = serde_json::from_str::( - &turn_context - .turn_metadata_state - .current_header_value() - .expect("turn metadata header"), - ) - .expect("turn metadata json"); + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), @@ -1011,13 +1047,10 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = serde_json::from_str::( - &turn_context - .turn_metadata_state - .current_header_value() - .expect("turn metadata header"), - ) - .expect("turn metadata json"); + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); assert_eq!( build_mcp_tool_call_request_meta( @@ -1035,6 +1068,251 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod ); } +fn codex_apps_auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + "connector_auth_failure": { + "is_auth_failure": true, + "auth_reason": "reauthentication_required", + "connector_id": "connector_calendar", + "connector_name": "Untrusted Calendar", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } +} + +fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata { + approval_metadata( + Some("connector_calendar"), + Some("Google Calendar"), + Some("Manage events and schedules."), + Some("Create Event"), + Some("Create a calendar event."), + ) +} + +async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) { + let auth = session.services.auth_manager.auth().await; + let environment = session + .services + .environment_manager + .default_environment() + .unwrap_or_else(|| session.services.environment_manager.local_environment()); + let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new( + &HashMap::new(), + turn_context.config.mcp_oauth_credentials_store_mode, + HashMap::new(), + &turn_context.approval_policy, + turn_context.sub_id.clone(), + session.get_tx_event(), + turn_context.permission_profile(), + codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()), + turn_context.config.codex_home.to_path_buf(), + codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), + /*host_owned_codex_apps_enabled*/ true, + codex_mcp::ToolPluginProvenance::default(), + auth.as_ref(), + /*elicitation_reviewer*/ None, + ) + .await; + *session.services.mcp_connection_manager.write().await = manager; +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() { + let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .features = ManagedFeatures::from(features); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + turn_context.features = ManagedFeatures::from(features); + turn_context + .approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + turn_context.features = ManagedFeatures::from(features); + turn_context + .approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: false, + })) + .expect("test setup should allow updating approval policy"); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .features = ManagedFeatures::from(features); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let request_task = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + async move { + maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result, + ) + .await + } + }); + + let request = loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv()) + .await + .expect("elicitation event timed out") + .expect("expected elicitation event"); + if let EventMsg::ElicitationRequest(request) = event.msg { + break request; + } + }; + assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!( + request.id, + codex_protocol::mcp::RequestId::String("codex_apps_auth_call_123".to_string()) + ); + assert!(matches!( + request.request, + codex_protocol::approvals::ElicitationRequest::Url { .. } + )); + + session + .resolve_elicitation( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + rmcp::model::RequestId::String("codex_apps_auth_call_123".into()), + ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: None, + }, + ) + .await + .expect("elicitation should resolve"); + let returned = tokio::time::timeout(std::time::Duration::from_secs(1), request_task) + .await + .expect("auth elicitation task timed out") + .expect("auth elicitation task failed"); + assert_eq!( + returned.content, + vec![serde_json::json!({ + "type": "text", + "text": "Authentication for Google Calendar was requested and accepted. Retry this tool call now.", + })] + ); +} + #[test] fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() { assert_eq!( diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index 975aecd4af..bb1cb43587 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -25,7 +25,7 @@ pub enum PersonalityMigrationStatus { pub async fn maybe_migrate_personality( codex_home: &Path, config_toml: &ConfigToml, - state_db: Option, + state_db: StateDbHandle, ) -> io::Result { let marker_path = codex_home.join(PERSONALITY_MIGRATION_FILENAME); if tokio::fs::try_exists(&marker_path).await? { @@ -65,16 +65,13 @@ pub async fn maybe_migrate_personality( async fn has_recorded_sessions( codex_home: &Path, default_provider: &str, - state_db: Option, + state_db: StateDbHandle, ) -> io::Result { - let store = LocalThreadStore::new( - LocalThreadStoreConfig { - codex_home: codex_home.to_path_buf(), - sqlite_home: codex_home.to_path_buf(), - default_model_provider_id: default_provider.to_string(), - }, - state_db, - ); + let config = LocalThreadStoreConfig { + codex_home: codex_home.to_path_buf(), + default_model_provider_id: default_provider.to_string(), + }; + let store = LocalThreadStore::new(config, state_db); if has_threads(&store, /*archived*/ false).await? { return Ok(true); } diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 28d34bc3c9..3de22ba1d3 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -8,7 +8,10 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::UserMessageEvent; use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; +use codex_rollout::RolloutConfig; use codex_rollout::SESSIONS_SUBDIR; +use codex_rollout::state_db::StateDbHandle; +use codex_state::state_db_path; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::io::AsyncWriteExt; @@ -20,6 +23,26 @@ async fn read_config_toml(codex_home: &Path) -> io::Result { toml::from_str(&contents).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } +async fn state_db_for_test(codex_home: &Path) -> io::Result { + state_db_for_test_with_sqlite_home(codex_home, codex_home).await +} + +async fn state_db_for_test_with_sqlite_home( + codex_home: &Path, + sqlite_home: &Path, +) -> io::Result { + let config = RolloutConfig { + codex_home: codex_home.to_path_buf(), + sqlite_home: sqlite_home.to_path_buf(), + cwd: codex_home.to_path_buf(), + model_provider_id: "openai".to_string(), + generate_memories: false, + }; + codex_rollout::state_db::try_init(&config) + .await + .map_err(io::Error::other) +} + async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> { let thread_id = ThreadId::new(); let dir = codex_home @@ -50,6 +73,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -87,7 +111,8 @@ async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> { write_session_with_user_event(temp.path()).await?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let state_db = state_db_for_test(temp.path()).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); @@ -103,7 +128,8 @@ async fn applies_when_only_archived_sessions_exist_and_no_personality() -> io::R write_archived_session_with_user_event(temp.path()).await?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let state_db = state_db_for_test(temp.path()).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); @@ -119,7 +145,8 @@ async fn skips_when_marker_exists() -> io::Result<()> { create_marker(&temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let state_db = state_db_for_test(temp.path()).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); assert!(!temp.path().join("config.toml").exists()); @@ -136,7 +163,8 @@ async fn skips_when_personality_explicit() -> io::Result<()> { .map_err(|err| io::Error::other(format!("failed to write config: {err}")))?; let config_toml = read_config_toml(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let state_db = state_db_for_test(temp.path()).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?; assert_eq!( status, @@ -153,10 +181,37 @@ async fn skips_when_personality_explicit() -> io::Result<()> { async fn skips_when_no_sessions() -> io::Result<()> { let temp = TempDir::new()?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let state_db = state_db_for_test(temp.path()).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); assert!(!temp.path().join("config.toml").exists()); Ok(()) } + +#[tokio::test] +async fn uses_configured_sqlite_home_when_checking_for_sessions() -> io::Result<()> { + let codex_home = TempDir::new()?; + let sqlite_home = TempDir::new()?; + write_session_with_user_event(codex_home.path()).await?; + + let config_toml = ConfigToml::default(); + let state_db = + state_db_for_test_with_sqlite_home(codex_home.path(), sqlite_home.path()).await?; + let status = maybe_migrate_personality(codex_home.path(), &config_toml, state_db).await?; + + assert_eq!(status, PersonalityMigrationStatus::Applied); + assert!( + codex_home + .path() + .join(PERSONALITY_MIGRATION_FILENAME) + .exists() + ); + + let persisted = read_config_toml(codex_home.path()).await?; + assert_eq!(persisted.personality, Some(Personality::Pragmatic)); + assert!(!state_db_path(codex_home.path()).exists()); + assert!(state_db_path(sqlite_home.path()).exists()); + Ok(()) +} diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 7c6144c10e..1b0c75230c 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -13,11 +13,13 @@ use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; use crate::config::Config; +use crate::resolve_installation_id; use crate::session::session::Session; use crate::session::turn::build_prompt; use crate::session::turn::built_tools; -use crate::state_db_bridge::StateDbHandle; use crate::thread_manager::ThreadManager; +use crate::thread_manager::agent_graph_store_from_state_db; +use crate::thread_manager::init_state_db_from_config; use crate::thread_manager::thread_store_from_config; /// Build the model-visible `input` list for a single debug turn. @@ -25,7 +27,6 @@ use crate::thread_manager::thread_store_from_config; pub async fn build_prompt_input( mut config: Config, input: Vec, - state_db: Option, ) -> CodexResult> { config.ephemeral = true; @@ -37,15 +38,22 @@ pub async fn build_prompt_input( config.codex_linux_sandbox_exe.clone(), )?; + let state_db = init_state_db_from_config(&config) + .await + .ok_or_else(|| std::io::Error::other("prompt debug requires state db"))?; let thread_store = thread_store_from_config(&config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( &config, Arc::clone(&auth_manager), SessionSource::Exec, Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await), /*analytics_events_client*/ None, + state_db, thread_store, - state_db.clone(), + agent_graph_store, + installation_id, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 9c1eb3af4b..f36239f2ae 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -49,6 +49,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre cwd: PathBuf::from(cwd), cli_version: "test".to_string(), source: SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index d4ac5c699a..e0d268dc2d 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -50,6 +50,7 @@ pub(crate) mod list { pub use codex_rollout::find_thread_path_by_id_str; } +#[cfg(test)] pub(crate) mod recorder { pub use codex_rollout::RolloutRecorder; } diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index f0a8d209d3..10f2242647 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -1,12 +1,19 @@ use anyhow::Context; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; +use codex_config::types::MemoriesToml; +use codex_features::AppsMcpPathOverrideConfigToml; +use codex_features::Feature; +use codex_features::FeatureToml; +use codex_features::FeaturesToml; +use codex_features::MultiAgentV2ConfigToml; use codex_protocol::ThreadId; -use crate::config::template_interpolation::materialized_config_toml; +use crate::config::Config; use crate::config_lock::ConfigLockReplayOptions; use crate::config_lock::clear_config_lock_debug_controls; use crate::config_lock::config_lockfile; +use crate::config_lock::toml_round_trip; use crate::config_lock::validate_config_lock_replay; use super::SessionConfiguration; @@ -74,12 +81,20 @@ fn session_configuration_to_lock_config_toml( sc: &SessionConfiguration, ) -> anyhow::Result { let config = sc.original_config_do_not_use.as_ref(); - let mut lock_config = materialized_config_toml(config)?; + // Start from the resolved layer stack, then patch in values that are only + // known after session setup. Export and replay validation both use this + // path, so every field here is part of the lockfile contract. + let mut lock_config: ConfigToml = config + .config_layer_stack + .effective_config() + .try_into() + .context("failed to deserialize effective config for config lock")?; if config.config_lock_save_fields_resolved_from_model_catalog { save_session_resolved_fields(sc, &mut lock_config); } + save_config_resolved_fields(config, &mut lock_config)?; drop_lockfile_inputs(&mut lock_config); Ok(lock_config) @@ -94,7 +109,10 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con lock_config.model = Some(sc.collaboration_mode.model().to_string()); lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort(); lock_config.model_reasoning_summary = sc.model_reasoning_summary; - lock_config.service_tier = sc.service_tier; + lock_config.service_tier = sc + .service_tier + .as_deref() + .and_then(codex_protocol::config_types::ServiceTier::from_request_value); lock_config.instructions = Some(sc.base_instructions.clone()); lock_config.developer_instructions = sc.developer_instructions.clone(); lock_config.compact_prompt = sc.compact_prompt.clone(); @@ -103,6 +121,64 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con lock_config.approvals_reviewer = Some(sc.approvals_reviewer); } +/// Saves values stored on `Config` after higher-level resolution, +/// normalization, defaulting, or feature materialization. +/// +/// Persist the resolved representation so replay compares against the behavior +/// Codex actually ran with, not only the user-authored TOML inputs. +fn save_config_resolved_fields( + config: &Config, + lock_config: &mut ConfigToml, +) -> anyhow::Result<()> { + lock_config.web_search = Some(config.web_search_mode.value()); + lock_config.model_provider = Some(config.model_provider_id.clone()); + lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; + lock_config.model_verbosity = config.model_verbosity; + lock_config.include_permissions_instructions = Some(config.include_permissions_instructions); + lock_config.include_apps_instructions = Some(config.include_apps_instructions); + lock_config.include_environment_context = Some(config.include_environment_context); + lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout); + + // Feature aliases and feature configs need to be written in their resolved + // form; otherwise replay can drift when a legacy key maps to the same + // runtime feature. + let features = lock_config + .features + .get_or_insert_with(FeaturesToml::default); + features.materialize_resolved_enabled(config.features.get()); + let mut multi_agent_v2: MultiAgentV2ConfigToml = + resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; + multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); + features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml { + enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)), + path: config.apps_mcp_path_override.clone(), + })); + lock_config.memories = Some(resolved_config_to_toml::( + &config.memories, + "memories", + )?); + + let agents = lock_config.agents.get_or_insert_with(Default::default); + // Multi-agent v2 owns thread fanout through its feature config. Preserve + // the legacy agents.max_threads setting only when v2 is disabled. + agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) { + None + } else { + config.agent_max_threads + }; + agents.max_depth = Some(config.agent_max_depth); + agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; + agents.interrupt_message = Some(config.agent_interrupt_message_enabled); + + lock_config + .skills + .get_or_insert_with(Default::default) + .include_instructions = Some(config.include_skill_instructions); + + Ok(()) +} + fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { // The lockfile should contain replayable values, not the profile, // debug-control, file-include, and environment-specific inputs that @@ -122,11 +198,19 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { lock_config.experimental_use_freeform_apply_patch = None; } +fn resolved_config_to_toml( + value: &impl serde::Serialize, + label: &'static str, +) -> anyhow::Result +where + Toml: serde::de::DeserializeOwned + serde::Serialize, +{ + toml_round_trip(value, label).map_err(anyhow::Error::from) +} + #[cfg(test)] mod tests { use super::*; - use codex_features::FeatureToml; - use codex_features::MultiAgentV2ConfigToml; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -199,7 +283,7 @@ mod tests { sc.base_instructions = "catalog instructions".to_string(); sc.developer_instructions = Some("catalog developer instructions".to_string()); sc.compact_prompt = Some("catalog compact prompt".to_string()); - sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex); + sc.service_tier = Some("flex".to_string()); let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize"); let lock = &lockfile.config; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 612eaf5d65..03f29bac53 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -18,20 +18,12 @@ use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET; use crate::realtime_context::truncate_realtime_text_to_token_budget; use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX; use crate::realtime_conversation::prefix_realtime_v2_text; -use crate::session::spawn_review_thread; -use codex_config::CloudRequirementsLoader; -use codex_config::LoaderOverrides; -use codex_config::loader::load_config_layers_state; -use codex_exec_server::LOCAL_FS; -use codex_utils_absolute_path::AbsolutePathBuf; - use crate::review_prompts::resolve_review_request; +use crate::session::spawn_review_thread; use crate::tasks::CompactTask; use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; -use codex_mcp::collect_mcp_snapshot_from_manager; -use codex_mcp::compute_auth_statuses; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::CodexErrorInfo; @@ -41,7 +33,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::InterAgentCommunication; -use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationListVoicesResponseEvent; @@ -49,10 +40,7 @@ use codex_protocol::protocol::RealtimeVoicesList; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SkillErrorInfo; -use codex_protocol::protocol::SkillsListEntry; use codex_protocol::protocol::ThreadMemoryMode; -use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; @@ -70,7 +58,6 @@ use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use serde_json::Value; -use std::path::PathBuf; use std::sync::Arc; use tracing::debug; use tracing::info; @@ -268,8 +255,11 @@ pub(super) async fn user_input_or_turn_inner( .set_responsesapi_client_metadata(responsesapi_client_metadata); } current_context.session_telemetry.user_prompt(&items); - sess.refresh_mcp_servers_if_requested(¤t_context) - .await; + sess.refresh_mcp_servers_if_requested( + ¤t_context, + Some(sess.mcp_elicitation_reviewer()), + ) + .await; let accepted_items = items.clone(); sess.spawn_task( Arc::clone(¤t_context), @@ -471,53 +461,6 @@ pub async fn dynamic_tool_response(sess: &Arc, id: String, response: Dy sess.notify_dynamic_tool_response(&id, response).await; } -pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { - let id = sess.conversation_id; - let config = Arc::clone(config); - tokio::spawn(async move { - if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await { - warn!("failed to append to message history: {e}"); - } - }); -} - -pub async fn get_history_entry_request( - sess: &Arc, - config: &Arc, - sub_id: String, - offset: usize, - log_id: u64, -) { - let config = Arc::clone(config); - let sess_clone = Arc::clone(sess); - - tokio::spawn(async move { - // Run lookup in blocking thread because it does file IO + locking. - let entry_opt = tokio::task::spawn_blocking(move || { - crate::message_history::lookup(log_id, offset, &config) - }) - .await - .unwrap_or(None); - - let event = Event { - id: sub_id, - msg: EventMsg::GetHistoryEntryResponse( - codex_protocol::protocol::GetHistoryEntryResponseEvent { - offset, - log_id, - entry: entry_opt.map(|e| codex_protocol::message_history::HistoryEntry { - conversation_id: e.session_id, - ts: e.ts, - text: e.text, - }), - }, - ), - }; - - sess_clone.send_event_raw(event).await; - }); -} - pub async fn refresh_mcp_servers(sess: &Arc, refresh_config: McpServerRefreshConfig) { let mut guard = sess.pending_mcp_server_refresh_config.lock().await; *guard = Some(refresh_config); @@ -527,127 +470,6 @@ pub async fn reload_user_config(sess: &Arc) { sess.reload_user_config_layer().await; } -#[expect( - clippy::await_holding_invalid_type, - reason = "MCP tool listing reads through the session-owned manager guard" -)] -pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { - let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; - let auth = sess.services.auth_manager.auth().await; - let mcp_servers = sess - .services - .mcp_manager - .effective_servers(config, auth.as_ref()) - .await; - let snapshot = collect_mcp_snapshot_from_manager( - &mcp_connection_manager, - compute_auth_statuses( - mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - auth.as_ref(), - ) - .await, - ) - .await; - let event = Event { - id: sub_id, - msg: EventMsg::McpListToolsResponse(snapshot), - }; - sess.send_event_raw(event).await; -} - -pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec, force_reload: bool) { - let default_cwd = { - let state = sess.state.lock().await; - state.session_configuration.cwd.to_path_buf() - }; - let cwds = if cwds.is_empty() { - vec![default_cwd] - } else { - cwds - }; - - let skills_manager = &sess.services.skills_manager; - let plugins_manager = &sess.services.plugins_manager; - let fs = sess - .services - .environment_manager - .default_environment() - .map(|environment| environment.get_filesystem()); - let config = sess.get_config().await; - let codex_home = sess.codex_home().await; - let mut skills = Vec::new(); - let empty_cli_overrides: &[(String, toml::Value)] = &[]; - for cwd in cwds { - let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) { - Ok(path) => path, - Err(err) => { - let error_path = cwd.clone(); - skills.push(SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![SkillErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let config_layer_stack = match load_config_layers_state( - LOCAL_FS.as_ref(), - &codex_home, - Some(cwd_abs.clone()), - empty_cli_overrides, - LoaderOverrides::default(), - CloudRequirementsLoader::default(), - &codex_config::NoopThreadConfigLoader, - ) - .await - { - Ok(config_layer_stack) => config_layer_stack, - Err(err) => { - let error_path = cwd.clone(); - skills.push(SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![SkillErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let plugins_input = config.plugins_config_input(); - let effective_skill_roots = plugins_manager - .effective_skill_roots_for_layer_stack(&config_layer_stack, &plugins_input) - .await; - let skills_input = crate::SkillsLoadInput::new( - cwd_abs.clone(), - effective_skill_roots, - config_layer_stack, - config.bundled_skills_enabled(), - ); - let outcome = skills_manager - .skills_for_cwd(&skills_input, force_reload, fs.clone()) - .await; - let errors = super::errors_to_info(&outcome.errors); - let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths); - skills.push(SkillsListEntry { - cwd, - skills: skills_metadata, - errors, - }); - } - - let event = Event { - id: sub_id, - msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }), - }; - sess.send_event_raw(event).await; -} - pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; @@ -763,21 +585,6 @@ pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32 .await; } -async fn persist_thread_name_update( - sess: &Arc, - event: ThreadNameUpdatedEvent, -) -> anyhow::Result { - let msg = EventMsg::ThreadNameUpdated(event); - let item = RolloutItem::EventMsg(msg.clone()); - let live_thread = sess.live_thread_for_persistence("rename thread")?; - live_thread.persist().await?; - live_thread - .append_items(std::slice::from_ref(&item)) - .await?; - live_thread.flush().await?; - Ok(msg) -} - pub(super) async fn persist_thread_memory_mode_update( sess: &Arc, mode: ThreadMemoryMode, @@ -792,65 +599,6 @@ pub(super) async fn persist_thread_memory_mode_update( Ok(()) } -/// Persists the thread name in the rollout and state database, updates in-memory state, and -/// emits a `ThreadNameUpdated` event on success. -pub async fn set_thread_name(sess: &Arc, sub_id: String, name: String) { - let Some(name) = crate::util::normalize_thread_name(&name) else { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: "Thread name cannot be empty.".to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }; - sess.send_event_raw(event).await; - return; - }; - - let updated = ThreadNameUpdatedEvent { - thread_id: sess.conversation_id, - thread_name: Some(name.clone()), - }; - - let msg = match persist_thread_name_update(sess, updated).await { - Ok(msg) => msg, - Err(err) => { - warn!("Failed to persist thread name update to rollout: {err}"); - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }; - sess.send_event_raw(event).await; - return; - } - }; - - if let Some(state_db) = sess.services.state_db.as_deref() - && let Err(err) = state_db - .update_thread_title(sess.conversation_id, &name) - .await - { - warn!("Failed to update thread title in state db: {err}"); - } - - { - let mut state = sess.state.lock().await; - state.session_configuration.thread_name = Some(name.clone()); - } - - let codex_home = sess.codex_home().await; - if let Err(err) = - crate::rollout::append_thread_name(&codex_home, sess.conversation_id, &name).await - { - warn!("Failed to update legacy thread name index: {err}"); - } - - sess.deliver_event_raw(Event { id: sub_id, msg }).await; -} - /// Persists thread-level memory mode metadata for the active session. /// /// This does not involve the model and only affects whether the thread is @@ -934,7 +682,8 @@ pub async fn review( let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) .await; - sess.refresh_mcp_servers_if_requested(&turn_context).await; + sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer())) + .await; match resolve_review_request(review_request, &turn_context.cwd) { Ok(resolved) => { spawn_review_thread( @@ -1087,18 +836,6 @@ pub(super) async fn submission_loop( dynamic_tool_response(&sess, id, response).await; false } - Op::AddToHistory { text } => { - add_to_history(&sess, &config, text).await; - false - } - Op::GetHistoryEntryRequest { offset, log_id } => { - get_history_entry_request(&sess, &config, sub.id.clone(), offset, log_id).await; - false - } - Op::ListMcpTools => { - list_mcp_tools(&sess, &config, sub.id.clone()).await; - false - } Op::RefreshMcpServers { config } => { refresh_mcp_servers(&sess, config).await; false @@ -1107,10 +844,6 @@ pub(super) async fn submission_loop( reload_user_config(&sess).await; false } - Op::ListSkills { cwds, force_reload } => { - list_skills(&sess, sub.id.clone(), cwds, force_reload).await; - false - } Op::Compact => { compact(&sess, sub.id.clone()).await; false @@ -1119,10 +852,6 @@ pub(super) async fn submission_loop( thread_rollback(&sess, sub.id.clone(), num_turns).await; false } - Op::SetThreadName { name } => { - set_thread_name(&sess, sub.id.clone(), name).await; - false - } Op::SetThreadMemoryMode { mode } => { set_thread_memory_mode(&sess, sub.id.clone(), mode).await; false diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 5cdc1daa65..ad04d30c66 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -1,6 +1,66 @@ use super::*; +use codex_mcp::ElicitationReviewRequest; +use codex_mcp::ElicitationReviewer; +use codex_mcp::ElicitationReviewerHandle; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY as MCP_ELICITATION_APPROVALS_REVIEWER_KEY; +use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY; +use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_ELICITATION_CONNECTOR_ID_KEY; +use codex_protocol::mcp_approval_meta::CONNECTOR_NAME_KEY as MCP_ELICITATION_CONNECTOR_NAME_KEY; +use codex_protocol::mcp_approval_meta::REQUEST_TYPE_APPROVAL_REQUEST as MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST; +use codex_protocol::mcp_approval_meta::REQUEST_TYPE_KEY as MCP_ELICITATION_REQUEST_TYPE_KEY; +use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_TOOL_DESCRIPTION_KEY; +use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY; +use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY; +use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::Meta; +use serde_json::Map; + +const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message"; + +#[derive(Debug, PartialEq)] +enum GuardianElicitationReview { + NotRequested, + Decline(&'static str), + ApprovalRequest(Box), +} + +struct GuardianMcpElicitationReviewer { + session: std::sync::Weak, +} + +impl GuardianMcpElicitationReviewer { + fn new(session: &Arc) -> Self { + Self { + session: Arc::downgrade(session), + } + } +} + +impl ElicitationReviewer for GuardianMcpElicitationReviewer { + fn review( + &self, + request: ElicitationReviewRequest, + ) -> BoxFuture<'static, anyhow::Result>> { + let session = self.session.clone(); + Box::pin(async move { + let Some(session) = session.upgrade() else { + return Ok(None); + }; + review_guardian_mcp_elicitation(session, request).await + }) + } +} impl Session { + pub(crate) fn mcp_elicitation_reviewer(self: &Arc) -> ElicitationReviewerHandle { + Arc::new(GuardianMcpElicitationReviewer::new(self)) + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -11,6 +71,20 @@ impl Session { request_id: RequestId, params: McpServerElicitationRequestParams, ) -> Option { + if self + .services + .mcp_connection_manager + .read() + .await + .elicitations_auto_deny() + { + return Some(ElicitationResponse { + action: codex_rmcp_client::ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + }); + } + let server_name = params.server_name.clone(); let request = match params.request { McpServerElicitationRequest::Form { @@ -207,6 +281,7 @@ impl Session { turn_context: &TurnContext, mcp_servers: HashMap, store_mode: OAuthCredentialsStoreMode, + elicitation_reviewer: Option, ) { let auth = self.services.auth_manager.auth().await; let config = self.get_config().await; @@ -219,6 +294,8 @@ impl Session { .tool_plugin_provenance(config.as_ref()) .await; let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config); + let host_owned_codex_apps_enabled = + host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await; let mcp_runtime_environment = match turn_context.environments.primary() { @@ -250,10 +327,16 @@ impl Session { mcp_runtime_environment, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth.as_ref(), + elicitation_reviewer, ) .await; + { + let current_manager = self.services.mcp_connection_manager.read().await; + refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny()); + } { let mut guard = self.services.mcp_startup_cancellation_token.lock().await; if guard.is_cancelled() { @@ -269,7 +352,11 @@ impl Session { old_manager.shutdown().await; } - pub(crate) async fn refresh_mcp_servers_if_requested(&self, turn_context: &TurnContext) { + pub(crate) async fn refresh_mcp_servers_if_requested( + &self, + turn_context: &TurnContext, + elicitation_reviewer: Option, + ) { let refresh_config = { self.pending_mcp_server_refresh_config.lock().await.take() }; let Some(refresh_config) = refresh_config else { return; @@ -298,7 +385,7 @@ impl Session { } }; - self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode) + self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode, elicitation_reviewer) .await; } @@ -307,8 +394,9 @@ impl Session { turn_context: &TurnContext, mcp_servers: HashMap, store_mode: OAuthCredentialsStoreMode, + elicitation_reviewer: Option, ) { - self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode) + self.refresh_mcp_servers_inner(turn_context, mcp_servers, store_mode, elicitation_reviewer) .await; } @@ -329,3 +417,219 @@ impl Session { .cancel(); } } + +async fn review_guardian_mcp_elicitation( + session: Arc, + request: ElicitationReviewRequest, +) -> anyhow::Result> { + let Some((turn_context, _cancellation_token)) = + session.active_turn_context_and_cancellation_token().await + else { + return Ok(None); + }; + + if !crate::guardian::routes_approval_to_guardian(turn_context.as_ref()) { + return Ok(None); + } + + let guardian_request = match guardian_elicitation_review_request(&request) { + GuardianElicitationReview::NotRequested => return Ok(None), + GuardianElicitationReview::Decline(reason) => { + warn!( + server_name = %request.server_name, + request_id = %mcp_elicitation_request_id(&request.request_id), + reason, + "declining Guardian MCP elicitation before review" + ); + return Ok(Some(mcp_elicitation_decline_without_message())); + } + GuardianElicitationReview::ApprovalRequest(guardian_request) => *guardian_request, + }; + + let review_id = crate::guardian::new_guardian_review_id(); + let decision = crate::guardian::review_approval_request( + &session, + &turn_context, + review_id.clone(), + guardian_request, + /*retry_reason*/ None, + ) + .await; + Ok(Some( + mcp_elicitation_response_from_guardian_decision(session.as_ref(), &review_id, decision) + .await, + )) +} + +fn guardian_elicitation_review_request( + request: &ElicitationReviewRequest, +) -> GuardianElicitationReview { + let (meta, requested_schema) = match &request.elicitation { + CreateElicitationRequestParams::FormElicitationParams { + meta, + requested_schema, + .. + } => (meta, Some(requested_schema)), + CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => { + return if meta_requests_approval_request(meta) { + GuardianElicitationReview::Decline( + "guardian MCP elicitation review only supports form elicitations", + ) + } else { + GuardianElicitationReview::NotRequested + }; + } + }; + + let Some(meta) = meta.as_ref().map(|meta| &meta.0) else { + return GuardianElicitationReview::NotRequested; + }; + if metadata_str(meta, MCP_ELICITATION_REQUEST_TYPE_KEY) + != Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST) + { + return GuardianElicitationReview::NotRequested; + } + if metadata_str(meta, MCP_ELICITATION_APPROVAL_KIND_KEY) + != Some(MCP_ELICITATION_APPROVAL_KIND_MCP_TOOL_CALL) + { + return GuardianElicitationReview::Decline( + "guardian MCP elicitation metadata must declare mcp_tool_call approval kind", + ); + } + if requested_schema.is_some_and(|schema| !schema.properties.is_empty()) { + return GuardianElicitationReview::Decline( + "guardian MCP elicitation review only supports empty form schemas", + ); + } + + let Some(tool_name) = metadata_owned_string(meta, MCP_ELICITATION_TOOL_NAME_KEY) else { + return GuardianElicitationReview::Decline( + "guardian MCP elicitation metadata must include a non-empty tool_name", + ); + }; + let arguments = match meta.get(MCP_ELICITATION_TOOL_PARAMS_KEY) { + Some(value @ Value::Object(_)) => Some(value.clone()), + Some(_) => { + return GuardianElicitationReview::Decline( + "guardian MCP elicitation tool_params must be an object", + ); + } + None => Some(Value::Object(Map::new())), + }; + + GuardianElicitationReview::ApprovalRequest(Box::new( + crate::guardian::GuardianApprovalRequest::McpToolCall { + id: format!( + "mcp_elicitation:{}:{}", + request.server_name, + mcp_elicitation_request_id(&request.request_id) + ), + server: request.server_name.clone(), + tool_name, + arguments, + connector_id: metadata_owned_string(meta, MCP_ELICITATION_CONNECTOR_ID_KEY), + connector_name: metadata_owned_string(meta, MCP_ELICITATION_CONNECTOR_NAME_KEY), + connector_description: metadata_owned_string( + meta, + MCP_ELICITATION_CONNECTOR_DESCRIPTION_KEY, + ), + tool_title: metadata_owned_string(meta, MCP_ELICITATION_TOOL_TITLE_KEY), + tool_description: metadata_owned_string(meta, MCP_ELICITATION_TOOL_DESCRIPTION_KEY), + annotations: None, + }, + )) +} + +fn meta_requests_approval_request(meta: &Option) -> bool { + meta.as_ref() + .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_REQUEST_TYPE_KEY)) + == Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST) +} + +fn metadata_str<'a>(meta: &'a Map, key: &str) -> Option<&'a str> { + meta.get(key).and_then(Value::as_str) +} + +fn metadata_owned_string(meta: &Map, key: &str) -> Option { + metadata_str(meta, key) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn mcp_elicitation_request_id(id: &RequestId) -> String { + match id { + rmcp::model::NumberOrString::String(value) => value.to_string(), + rmcp::model::NumberOrString::Number(value) => value.to_string(), + } +} + +async fn mcp_elicitation_response_from_guardian_decision( + session: &Session, + review_id: &str, + decision: ReviewDecision, +) -> ElicitationResponse { + let denial_message = match decision { + ReviewDecision::Denied => { + Some(crate::guardian::guardian_rejection_message(session, review_id).await) + } + _ => None, + }; + mcp_elicitation_response_from_guardian_decision_parts(decision, denial_message) +} + +fn mcp_elicitation_response_from_guardian_decision_parts( + decision: ReviewDecision, + denial_message: Option, +) -> ElicitationResponse { + match decision { + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + | ReviewDecision::NetworkPolicyAmendment { .. } => ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: Some(mcp_elicitation_auto_meta()), + }, + ReviewDecision::Denied => mcp_elicitation_decline_with_message( + denial_message.unwrap_or_else(|| "Guardian denied this request.".to_string()), + ), + ReviewDecision::TimedOut => { + mcp_elicitation_decline_with_message(crate::guardian::guardian_timeout_message()) + } + ReviewDecision::Abort => ElicitationResponse { + action: ElicitationAction::Cancel, + content: None, + meta: Some(mcp_elicitation_auto_meta()), + }, + } +} + +fn mcp_elicitation_decline_with_message(message: String) -> ElicitationResponse { + ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: Some(serde_json::json!({ + MCP_ELICITATION_DECLINE_MESSAGE_KEY: message, + MCP_ELICITATION_APPROVALS_REVIEWER_KEY: ApprovalsReviewer::AutoReview, + })), + } +} + +fn mcp_elicitation_decline_without_message() -> ElicitationResponse { + ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: Some(mcp_elicitation_auto_meta()), + } +} + +fn mcp_elicitation_auto_meta() -> serde_json::Value { + serde_json::json!({ + MCP_ELICITATION_APPROVALS_REVIEWER_KEY: ApprovalsReviewer::AutoReview, + }) +} + +#[cfg(test)] +#[path = "mcp_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs new file mode 100644 index 0000000000..31b304faa5 --- /dev/null +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -0,0 +1,212 @@ +use super::*; +use rmcp::model::BooleanSchema; +use rmcp::model::ElicitationSchema; +use rmcp::model::PrimitiveSchema; +use serde_json::json; + +fn meta(value: Value) -> Option { + let Value::Object(map) = value else { + panic!("metadata must be an object"); + }; + Some(Meta(map)) +} + +fn guardian_meta(tool_params: Option) -> Option { + let mut value = json!({ + "codex_approval_kind": "mcp_tool_call", + "codex_request_type": "approval_request", + "connector_id": "browser-use", + "connector_name": "Browser Use", + "tool_name": "access_browser_origin", + "tool_title": "Access browser origin", + }); + if let Some(tool_params) = tool_params { + value["tool_params"] = tool_params; + } + meta(value) +} + +fn form_request(meta: Option) -> ElicitationReviewRequest { + ElicitationReviewRequest { + server_name: "browser-use".to_string(), + request_id: rmcp::model::NumberOrString::Number(7), + elicitation: CreateElicitationRequestParams::FormElicitationParams { + meta, + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .build() + .expect("schema should build"), + }, + } +} + +#[test] +fn guardian_elicitation_review_request_builds_mcp_tool_call() { + let request = form_request(guardian_meta(Some(json!({ + "origin": "https://example.com", + })))); + + let GuardianElicitationReview::ApprovalRequest(guardian_request) = + guardian_elicitation_review_request(&request) + else { + panic!("expected Guardian MCP tool call request"); + }; + let crate::guardian::GuardianApprovalRequest::McpToolCall { + id, + server, + tool_name, + arguments, + connector_id, + connector_name, + connector_description, + tool_title, + tool_description, + annotations, + } = *guardian_request + else { + panic!("expected Guardian MCP tool call request"); + }; + + assert_eq!(id, "mcp_elicitation:browser-use:7"); + assert_eq!(server, "browser-use"); + assert_eq!(tool_name, "access_browser_origin"); + assert_eq!(arguments, Some(json!({ "origin": "https://example.com" }))); + assert_eq!(connector_id.as_deref(), Some("browser-use")); + assert_eq!(connector_name.as_deref(), Some("Browser Use")); + assert_eq!(connector_description, None); + assert_eq!(tool_title.as_deref(), Some("Access browser origin")); + assert_eq!(tool_description, None); + assert_eq!(annotations, None); +} + +#[test] +fn guardian_elicitation_review_request_defaults_missing_tool_params() { + let request = form_request(guardian_meta(/*tool_params*/ None)); + + let GuardianElicitationReview::ApprovalRequest(guardian_request) = + guardian_elicitation_review_request(&request) + else { + panic!("expected Guardian MCP tool call request"); + }; + let crate::guardian::GuardianApprovalRequest::McpToolCall { arguments, .. } = *guardian_request + else { + panic!("expected Guardian MCP tool call request"); + }; + + assert_eq!(arguments, Some(json!({}))); +} + +#[test] +fn guardian_elicitation_review_request_requires_opt_in() { + let request = form_request(meta(json!({ + "codex_approval_kind": "mcp_tool_call", + "tool_name": "access_browser_origin", + }))); + + assert_eq!( + guardian_elicitation_review_request(&request), + GuardianElicitationReview::NotRequested + ); +} + +#[test] +fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { + let url_request = ElicitationReviewRequest { + server_name: "browser-use".to_string(), + request_id: rmcp::model::NumberOrString::Number(8), + elicitation: CreateElicitationRequestParams::UrlElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Open URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "elicit-1".to_string(), + }, + }; + assert!(matches!( + guardian_elicitation_review_request(&url_request), + GuardianElicitationReview::Decline(_) + )); + + let non_empty_schema_request = ElicitationReviewRequest { + server_name: "browser-use".to_string(), + request_id: rmcp::model::NumberOrString::Number(9), + elicitation: CreateElicitationRequestParams::FormElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .build() + .expect("schema should build"), + }, + }; + assert!(matches!( + guardian_elicitation_review_request(&non_empty_schema_request), + GuardianElicitationReview::Decline(_) + )); + + let missing_tool_name_request = form_request(meta(json!({ + "codex_approval_kind": "mcp_tool_call", + "codex_request_type": "approval_request", + }))); + assert!(matches!( + guardian_elicitation_review_request(&missing_tool_name_request), + GuardianElicitationReview::Decline(_) + )); +} + +#[test] +fn guardian_decisions_map_to_elicitation_responses_without_session_state() { + assert_eq!( + mcp_elicitation_response_from_guardian_decision_parts( + ReviewDecision::Approved, + /*denial_message*/ None, + ), + ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(json!({})), + meta: Some(json!({ + "approvals_reviewer": ApprovalsReviewer::AutoReview, + })), + } + ); + assert_eq!( + mcp_elicitation_response_from_guardian_decision_parts( + ReviewDecision::Denied, + Some("Denied by Guardian".to_string()), + ), + ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: Some(json!({ + "approvals_reviewer": ApprovalsReviewer::AutoReview, + "message": "Denied by Guardian", + })), + } + ); + assert_eq!( + mcp_elicitation_response_from_guardian_decision_parts( + ReviewDecision::TimedOut, + /*denial_message*/ None, + ), + ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: Some(json!({ + "approvals_reviewer": ApprovalsReviewer::AutoReview, + "message": crate::guardian::guardian_timeout_message(), + })), + } + ); + assert_eq!( + mcp_elicitation_response_from_guardian_decision_parts( + ReviewDecision::Abort, + /*denial_message*/ None, + ), + ElicitationResponse { + action: ElicitationAction::Cancel, + content: None, + meta: Some(json!({ + "approvals_reviewer": ApprovalsReviewer::AutoReview, + })), + } + ); +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 46d942f710..8782f14b3d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,7 +32,6 @@ use crate::context::PersonalitySpecInstructions; use crate::default_skill_metadata_budget; use crate::environment_selection::ResolvedTurnEnvironments; use crate::exec_policy::ExecPolicyManager; -use crate::installation_id::resolve_installation_id; use crate::parse_turn_item; use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; @@ -110,6 +109,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -132,7 +132,6 @@ use codex_terminal_detection::user_agent; use codex_thread_store::CreateThreadParams; use codex_thread_store::LiveThread; use codex_thread_store::LiveThreadInitGuard; -use codex_thread_store::LocalThreadStore; use codex_thread_store::ResumeThreadParams; use codex_thread_store::ThreadEventPersistenceMode; use codex_thread_store::ThreadPersistenceMetadata; @@ -267,8 +266,9 @@ pub(crate) struct PreviousTurnSettings { pub(crate) realtime_active: Option, } -use crate::SkillError; +#[cfg(test)] use crate::SkillLoadOutcome; +#[cfg(test)] use crate::SkillMetadata; use crate::SkillsManager; use crate::agents_md::AgentsMdManager; @@ -306,6 +306,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; use codex_git_utils::get_git_repo_root; use codex_mcp::compute_auth_statuses; +use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; @@ -343,11 +344,6 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionNetworkProxyRuntime; -use codex_protocol::protocol::SkillDependencies as ProtocolSkillDependencies; -use codex_protocol::protocol::SkillErrorInfo; -use codex_protocol::protocol::SkillInterface as ProtocolSkillInterface; -use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; -use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency; use codex_protocol::protocol::StreamErrorEvent; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadMemoryMode; @@ -356,7 +352,6 @@ use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; -use codex_tools::ToolEnvironmentMode; use codex_tools::ToolsConfig; use codex_tools::ToolsConfigParams; use codex_utils_absolute_path::AbsolutePathBuf; @@ -388,6 +383,7 @@ pub struct CodexSpawnOk { pub(crate) struct CodexSpawnArgs { pub(crate) config: Config, + pub(crate) installation_id: String, pub(crate) auth_manager: Arc, pub(crate) models_manager: SharedModelsManager, pub(crate) environment_manager: Arc, @@ -397,6 +393,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) skills_watcher: Arc, pub(crate) conversation_history: InitialHistory, pub(crate) session_source: SessionSource, + pub(crate) thread_source: Option, pub(crate) agent_control: AgentControl, pub(crate) dynamic_tools: Vec, pub(crate) persist_extended_history: bool, @@ -412,6 +409,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) parent_trace: Option, pub(crate) environment_selections: ResolvedTurnEnvironments, pub(crate) analytics_events_client: Option, + pub(crate) state_db: Option, pub(crate) thread_store: Arc, } @@ -449,6 +447,7 @@ impl Codex { async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult { let CodexSpawnArgs { mut config, + installation_id, auth_manager, models_manager, environment_manager, @@ -458,6 +457,7 @@ impl Codex { skills_watcher, conversation_history, session_source, + thread_source, agent_control, dynamic_tools, persist_extended_history, @@ -469,6 +469,7 @@ impl Codex { parent_trace: _, environment_selections, analytics_events_client, + state_db, thread_store, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -476,7 +477,7 @@ impl Codex { let fs = environment_selections.primary_filesystem(); let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; - let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await; @@ -557,15 +558,7 @@ impl Codex { }; match thread_id { Some(thread_id) => { - let state_db_ctx = if config.ephemeral { - None - } else if let Some(local_store) = - thread_store.as_any().downcast_ref::() - { - local_store.state_db().await - } else { - None - }; + let state_db_ctx = state_db.clone(); state_db::get_dynamic_tools(state_db_ctx.as_deref(), thread_id, "codex_spawn") .await } @@ -595,7 +588,7 @@ impl Codex { .auth_cached() .and_then(|auth| auth.account_plan_type()); let service_tier = get_service_tier( - config.service_tier, + config.service_tier.clone(), config.notices.fast_default_opt_out.unwrap_or(false), account_plan_type, config.features.enabled(Feature::FastMode), @@ -624,6 +617,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_source, dynamic_tools, persist_extended_history, inherited_shell_snapshot, @@ -637,6 +631,7 @@ impl Codex { let session = Session::new( session_configuration, config.clone(), + installation_id, auth_manager.clone(), models_manager.clone(), exec_policy, @@ -651,6 +646,7 @@ impl Codex { agent_control, environment_manager, analytics_events_client, + state_db, thread_store, parent_rollout_thread_trace, ) @@ -758,6 +754,7 @@ impl Codex { &self, app_server_client_name: Option, app_server_client_version: Option, + mcp_elicitations_auto_deny: bool, ) -> ConstraintResult<()> { self.session .update_settings(SessionSettingsUpdate { @@ -765,7 +762,10 @@ impl Codex { app_server_client_version, ..Default::default() }) - .await + .await?; + let mcp_connection_manager = self.session.services.mcp_connection_manager.read().await; + mcp_connection_manager.set_elicitations_auto_deny(mcp_elicitations_auto_deny); + Ok(()) } pub(crate) async fn agent_status(&self) -> AgentStatus { @@ -787,18 +787,18 @@ impl Codex { } fn get_service_tier( - configured_service_tier: Option, + configured_service_tier: Option, fast_default_opt_out: bool, account_plan_type: Option, fast_mode_enabled: bool, -) -> Option { +) -> Option { if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled { return configured_service_tier; } account_plan_type .is_some_and(is_enterprise_default_service_tier_plan) - .then_some(ServiceTier::Fast) + .then_some(ServiceTier::Fast.request_value().to_string()) } fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { @@ -987,6 +987,7 @@ impl Session { } } + #[cfg(test)] pub(crate) async fn codex_home(&self) -> AbsolutePathBuf { let state = self.state.lock().await; state.session_configuration.codex_home().clone() @@ -1307,7 +1308,7 @@ impl Session { self.services.user_shell.as_ref().clone(), self.services.shell_snapshot_tx.clone(), self.services.session_telemetry.clone(), - self.services.state_db.clone(), + self.state_db(), ); } @@ -2702,13 +2703,14 @@ impl Session { ); } if turn_context.config.include_environment_context { + let shell = self.user_shell(); let subagents = self .services .agent_control .format_environment_context_subagents(self.conversation_id) .await; contextual_user_sections.push( - crate::context::EnvironmentContext::from_turn_context(turn_context) + crate::context::EnvironmentContext::from_turn_context(turn_context, shell.as_ref()) .with_subagents(subagents) .render(), ); @@ -3296,60 +3298,6 @@ pub(crate) fn emit_subagent_session_started( }); } -fn skills_to_info( - skills: &[SkillMetadata], - disabled_paths: &HashSet, -) -> Vec { - skills - .iter() - .map(|skill| ProtocolSkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill - .interface - .clone() - .map(|interface| ProtocolSkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - }), - dependencies: skill.dependencies.clone().map(|dependencies| { - ProtocolSkillDependencies { - tools: dependencies - .tools - .into_iter() - .map(|tool| ProtocolSkillToolDependency { - r#type: tool.r#type, - value: tool.value, - description: tool.description, - transport: tool.transport, - command: tool.command, - url: tool.url, - }) - .collect(), - } - }), - path: skill.path_to_skills_md.clone(), - scope: skill.scope, - enabled: !disabled_paths.contains(&skill.path_to_skills_md), - }) - .collect() -} - -fn errors_to_info(errors: &[SkillError]) -> Vec { - errors - .iter() - .map(|err| SkillErrorInfo { - path: err.path.to_path_buf(), - message: err.message.clone(), - }) - .collect() -} - use codex_memories_read::build_memory_tool_developer_instructions; /// Builds the hook engine for one config snapshot, including any enabled plugin hooks. diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 67c1e33310..7d4b1b736a 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -102,8 +102,9 @@ pub(super) async fn spawn_review_thread( let per_turn_config = Arc::new(per_turn_config); let review_turn_id = sub_id.to_string(); let turn_metadata_state = Arc::new(TurnMetadataState::new( - sess.conversation_id.to_string(), - &session_source, + sess.session_id().to_string(), + sess.thread_id().to_string(), + parent_turn_context.thread_source, review_turn_id.clone(), parent_turn_context.cwd.clone(), &parent_turn_context.permission_profile, @@ -123,6 +124,7 @@ pub(super) async fn spawn_review_thread( reasoning_effort, reasoning_summary, session_source, + thread_source: parent_turn_context.thread_source, environments: parent_turn_context.environments.clone(), tools_config, features: parent_turn_context.features.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2444ded83f..2ce01e81b3 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,8 +1,10 @@ use super::*; use crate::goals::GoalRuntimeState; -use codex_otel::LEGACY_NOTIFY_CONFIGURED_METRIC; +use codex_protocol::SessionId; +use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use tokio::sync::Semaphore; @@ -11,6 +13,7 @@ use tokio::sync::Semaphore; /// A session has at most 1 running task at a time, and can be interrupted by user input. pub(crate) struct Session { pub(crate) conversation_id: ThreadId, + pub(crate) installation_id: String, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, pub(super) out_of_band_elicitation_paused: watch::Sender, @@ -40,7 +43,7 @@ pub(crate) struct SessionConfiguration { pub(super) collaboration_mode: CollaborationMode, pub(super) model_reasoning_summary: Option, - pub(super) service_tier: Option, + pub(super) service_tier: Option, /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, @@ -87,6 +90,8 @@ pub(crate) struct SessionConfiguration { pub(super) app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) pub(super) session_source: SessionSource, + /// Optional analytics source classification for this thread. + pub(super) thread_source: Option, pub(super) dynamic_tools: Vec, pub(super) persist_extended_history: bool, pub(super) inherited_shell_snapshot: Option>, @@ -132,7 +137,7 @@ impl SessionConfiguration { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), - service_tier: self.service_tier, + service_tier: self.service_tier.clone(), approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), @@ -142,6 +147,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + thread_source: self.thread_source, } } @@ -177,8 +183,15 @@ impl SessionConfiguration { if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); } - if let Some(service_tier) = updates.service_tier { - next_configuration.service_tier = service_tier; + if let Some(service_tier) = updates.service_tier.clone() { + // TODO(aibrahim): Remove once v2 clients no longer send the legacy + // "fast" service tier value. + next_configuration.service_tier = service_tier.map(|service_tier| { + ServiceTier::from_request_value(&service_tier) + .map_or(service_tier, |service_tier| { + service_tier.request_value().to_string() + }) + }); } if let Some(personality) = updates.personality { next_configuration.personality = Some(personality); @@ -304,7 +317,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, - pub(crate) service_tier: Option>, + pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, /// Turn-local environment override. `None` inherits the sticky thread /// environments stored on `SessionConfiguration`; `Some([])` explicitly @@ -321,6 +334,16 @@ pub(crate) struct AppServerClientMetadata { } impl Session { + /// Returns the concrete identity for this thread. + pub(crate) fn thread_id(&self) -> ThreadId { + self.conversation_id + } + + /// Returns the identity shared by the root thread and all descendant threads. + pub(crate) fn session_id(&self) -> SessionId { + self.services.agent_control.session_id() + } + #[instrument(name = "session_init", level = "info", skip_all)] #[allow(clippy::too_many_arguments)] #[expect( @@ -330,6 +353,7 @@ impl Session { pub(crate) async fn new( mut session_configuration: SessionConfiguration, config: Arc, + installation_id: String, auth_manager: Arc, models_manager: SharedModelsManager, exec_policy: Arc, @@ -344,6 +368,7 @@ impl Session { agent_control: AgentControl, environment_manager: Arc, analytics_events_client: Option, + state_db: Option, thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, ) -> anyhow::Result> { @@ -359,7 +384,7 @@ impl Session { } else { ThreadEventPersistenceMode::Limited }; - let conversation_id = match &initial_history { + let thread_id = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { ThreadId::default() } @@ -390,9 +415,10 @@ impl Session { LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { - thread_id: conversation_id, + thread_id, forked_from_id, source: session_source, + thread_source: session_configuration.thread_source, base_instructions: BaseInstructions { text: session_configuration.base_instructions.clone(), }, @@ -442,36 +468,8 @@ impl Session { otel.name = "session_init.thread_persistence", session_init.ephemeral = config.ephemeral, )); - let state_db_fut = async { - if config.ephemeral { - None - } else if let Some(local_store) = - thread_store.as_any().downcast_ref::() - { - local_store.state_db().await - } else { - None - } - } - .instrument(info_span!( - "session_init.state_db", - otel.name = "session_init.state_db", - session_init.ephemeral = config.ephemeral, - )); + let state_db_ctx = if config.ephemeral { None } else { state_db }; - let is_subagent = session_configuration.session_source.is_non_root_agent(); - let history_meta_fut = async { - if is_subagent { - (0, 0) - } else { - crate::message_history::history_metadata(&config).await - } - } - .instrument(info_span!( - "session_init.history_metadata", - otel.name = "session_init.history_metadata", - session_init.is_subagent = is_subagent, - )); let auth_manager_clone = Arc::clone(&auth_manager); let config_for_mcp = Arc::clone(&config); let mcp_manager_for_mcp = Arc::clone(&mcp_manager); @@ -494,17 +492,8 @@ impl Session { )); // Join all independent futures. - let ( - thread_persistence_result, - state_db_ctx, - (history_log_id, history_entry_count), - (auth, mcp_servers, auth_statuses), - ) = tokio::join!( - thread_persistence_fut, - state_db_fut, - history_meta_fut, - auth_and_mcp_fut - ); + let (thread_persistence_result, (auth, mcp_servers, auth_statuses)) = + tokio::join!(thread_persistence_fut, auth_and_mcp_fut); let mut live_thread_init = LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| { @@ -524,7 +513,7 @@ impl Session { let trace_task_name = (!trace_agent_path.is_root()).then(|| trace_agent_path.name().to_string()); let trace_metadata = ThreadStartedTraceMetadata { - thread_id: conversation_id.to_string(), + thread_id: thread_id.to_string(), agent_path: trace_agent_path.to_string(), task_name: trace_task_name, nickname: session_configuration.session_source.get_nickname(), @@ -573,24 +562,6 @@ impl Session { }), }); } - let legacy_notify_configured = config - .notify - .as_ref() - .is_some_and(|argv| !argv.is_empty() && !argv[0].is_empty()); - if legacy_notify_configured { - post_session_configured_events.push(Event { - id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { - summary: - "`notify` is deprecated and will be removed in a future release." - .to_string(), - details: Some( - "Switch to a `Stop` hook for end-of-turn automation. See https://developers.openai.com/codex/hooks." - .to_string(), - ), - }), - }); - } for message in &config.startup_warnings { post_session_configured_events.push(Event { id: "".to_owned(), @@ -633,7 +604,7 @@ impl Session { auth_manager.codex_api_key_env_enabled(), ); let mut session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, session_model.as_str(), session_model.as_str(), account_id.clone(), @@ -648,11 +619,8 @@ impl Session { if let Some(service_name) = session_configuration.metrics_service_name.as_deref() { session_telemetry = session_telemetry.with_metrics_service_name(service_name); } - if legacy_notify_configured { - session_telemetry.counter(LEGACY_NOTIFY_CONFIGURED_METRIC, /*inc*/ 1, &[]); - } let network_proxy_audit_metadata = NetworkProxyAuditMetadata { - conversation_id: Some(conversation_id.to_string()), + conversation_id: Some(thread_id.to_string()), app_version: Some(env!("CARGO_PKG_VERSION").to_string()), user_account_id: account_id, auth_mode: auth_mode.map(|mode| mode.to_string()), @@ -722,7 +690,7 @@ impl Session { } else { ShellSnapshot::start_snapshotting( config.codex_home.clone(), - conversation_id, + thread_id, session_configuration.cwd.clone(), &mut default_shell, session_telemetry.clone(), @@ -735,7 +703,7 @@ impl Session { tx }; let thread_name = - thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, conversation_id) + thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, thread_id) .instrument(info_span!( "session_init.thread_name_lookup", otel.name = "session_init.thread_name_lookup", @@ -743,7 +711,7 @@ impl Session { .await; session_configuration.thread_name = thread_name.clone(); validate_config_lock_if_configured(&session_configuration).await?; - export_config_lock_if_configured(&session_configuration, conversation_id).await?; + export_config_lock_if_configured(&session_configuration, thread_id).await?; let state = SessionState::new(session_configuration.clone()); let managed_network_requirements_configured = config .config_layer_stack @@ -815,7 +783,6 @@ impl Session { }); } - let installation_id = resolve_installation_id(&config.codex_home).await?; let analytics_events_client = analytics_events_client.unwrap_or_else(|| { AnalyticsEventsClient::new( Arc::clone(&auth_manager), @@ -823,6 +790,12 @@ impl Session { config.analytics_enabled, ) }); + let session_id = if session_configuration.session_source.is_non_root_agent() { + agent_control.session_id() + } else { + SessionId::from(thread_id) + }; + let agent_control = agent_control.with_session_id(session_id); let services = SessionServices { // Initialize the MCP connection manager with an uninitialized // instance. It will be replaced with one created via @@ -867,8 +840,9 @@ impl Session { thread_store: Arc::clone(&thread_store), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), - conversation_id, - installation_id, + session_id, + thread_id, + installation_id.clone(), session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, @@ -887,7 +861,8 @@ impl Session { let (mailbox, mailbox_rx) = Mailbox::new(); let sess = Arc::new(Session { - conversation_id, + conversation_id: thread_id, + installation_id, tx_event: tx_event.clone(), agent_status, out_of_band_elicitation_paused, @@ -915,20 +890,20 @@ impl Session { let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: conversation_id, + session_id, + thread_id, forked_from_id, + thread_source: session_configuration.thread_source, thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), - service_tier: session_configuration.service_tier, + service_tier: session_configuration.service_tier.clone(), approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), - history_log_id, - history_entry_count, initial_messages, network_proxy: session_network_proxy.filter(|_| { Self::managed_network_proxy_active_for_permission_profile( @@ -954,6 +929,9 @@ impl Session { let enabled_mcp_server_count = mcp_servers.values().filter(|server| server.enabled).count(); let required_mcp_server_count = required_mcp_servers.len(); let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref()).await; + let host_owned_codex_apps_enabled = config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(|auth| auth.uses_codex_backend())); { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -995,8 +973,10 @@ impl Session { mcp_runtime_environment, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth, + Some(sess.mcp_elicitation_reviewer()), ) .instrument(info_span!( "session_init.mcp_manager_init", diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index fe38cd7f64..cae3e1f978 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -30,6 +30,7 @@ use codex_models_manager::model_info; use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; use codex_protocol::AgentPath; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; @@ -52,6 +53,8 @@ use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; +use crate::goals::ExternalGoalPreviousStatus; +use crate::goals::ExternalGoalSet; use crate::goals::GoalRuntimeEvent; use crate::goals::SetGoalRequest; use crate::rollout::recorder::RolloutRecorder; @@ -64,13 +67,15 @@ use crate::tasks::execute_user_shell_command; use crate::tools::ToolRouter; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::GoalHandler; +use crate::tools::handlers::CreateGoalHandler; +use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ShellHandler; -use crate::tools::handlers::UnifiedExecHandler; +use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolHandler; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::McpElicitationSchema; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_execpolicy::Decision; @@ -121,6 +126,7 @@ use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::ElicitationAction; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::context_snapshot; @@ -290,6 +296,49 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; } +#[tokio::test] +async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() { + let (session, turn_context, rx) = make_session_and_context_with_rx().await; + session + .services + .mcp_connection_manager + .read() + .await + .set_elicitations_auto_deny(/*auto_deny*/ true); + + let requested_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": {}, + })) + .expect("schema should deserialize"); + let response = session + .request_mcp_server_elicitation( + turn_context.as_ref(), + RequestId::String("request-1".into()), + McpServerElicitationRequestParams { + thread_id: session.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema, + }, + }, + ) + .await; + + assert_eq!( + response, + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(json!({})), + meta: None, + }) + ); + assert!(rx.try_recv().is_err()); +} + #[tokio::test] async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; @@ -345,10 +394,12 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted } fn test_model_client_session() -> crate::client::ModelClientSession { + let thread_id = ThreadId::try_from("00000000-0000-4000-8000-000000000001") + .expect("test thread id should be valid"); crate::client::ModelClient::new( /*auth_manager*/ None, - ThreadId::try_from("00000000-0000-4000-8000-000000000001") - .expect("test thread id should be valid"), + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None), codex_protocol::protocol::SessionSource::Exec, @@ -864,7 +915,7 @@ async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyho &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), )?; - let session = make_session_with_config(move |config| { + let (session, _codex_home) = make_session_with_config(move |config| { let cwd = config.cwd.clone(); config .permissions @@ -930,7 +981,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), )?; - let session = make_session_with_config(move |config| { + let (session, _codex_home) = make_session_with_config(move |config| { let cwd = config.cwd.clone(); config .permissions @@ -1005,7 +1056,7 @@ async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> any &permission_profile_for_sandbox_policy(&sandbox_policy), )?; - let session = make_session_with_config(move |config| { + let (session, _codex_home) = make_session_with_config(move |config| { let cwd = config.cwd.clone(); config .permissions @@ -1032,7 +1083,7 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R &permission_profile_for_sandbox_policy(&sandbox_policy), )?; - let (session, rx) = make_session_with_config_and_rx(move |config| { + let (session, rx, _codex_home) = make_session_with_config_and_rx(move |config| { let cwd = config.cwd.clone(); config .permissions @@ -1162,7 +1213,7 @@ async fn reload_user_config_layer_updates_effective_apps_config() { #[tokio::test] async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { - let session = make_session_with_config(|config| { + let (session, _codex_home) = make_session_with_config(|config| { config .features .enable(Feature::CodexHooks) @@ -1171,15 +1222,17 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { .await?; let codex_home = session.codex_home().await; std::fs::create_dir_all(&codex_home)?; - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#" -[hooks] - -[[hooks.SessionStart]] -hooks = [{ type = "command", command = "python3 /tmp/user.py" }] -"#, - )?; + let config_toml_path = codex_home.join(CONFIG_TOML_FILE); + let user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": "python3 /tmp/user.py", + }], + }], + }, + }))?; let request = codex_hooks::SessionStartRequest { session_id: session.conversation_id, @@ -1191,6 +1244,39 @@ hooks = [{ type = "command", command = "python3 /tmp/user.py" }] }; assert!(session.hooks().preview_session_start(&request).is_empty()); + let config = session.get_config().await; + let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: true, + config_layer_stack: Some( + config + .config_layer_stack + .with_user_config(&config_toml_path, user_config.clone()), + ), + ..codex_hooks::HooksConfig::default() + }); + assert_eq!(hook_list.hooks.len(), 1); + assert_eq!( + hook_list.hooks[0].trust_status, + codex_protocol::protocol::HookTrustStatus::Untrusted + ); + + let trusted_user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": "python3 /tmp/user.py", + }], + }], + "state": { + hook_list.hooks[0].key.clone(): { + "trusted_hash": hook_list.hooks[0].current_hash.clone(), + }, + }, + }, + }))?; + std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?; + session.reload_user_config_layer().await; assert_eq!(session.hooks().preview_session_start(&request).len(), 1); @@ -1684,6 +1770,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< usize::MAX, fork_config.clone(), rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -2373,6 +2460,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2476,6 +2564,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2728,6 +2817,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { thread_id: session.conversation_id, forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { @@ -2801,7 +2891,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2810,7 +2900,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::EnterpriseCbpUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2819,7 +2909,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Business), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2828,7 +2918,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Team), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2837,7 +2927,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::SelfServeBusinessUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); } @@ -2890,6 +2980,23 @@ async fn session_settings_null_service_tier_update_clears_service_tier() { assert_eq!(updated.service_tier, None); } +#[tokio::test] +async fn session_settings_legacy_fast_service_tier_update_uses_priority_request_value() { + let session_configuration = make_session_configuration_for_tests().await; + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + service_tier: Some(Some("fast".to_string())), + ..Default::default() + }) + .expect("legacy fast service tier update should apply"); + + assert_eq!( + updated.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration { let codex_home = tempfile::tempdir().expect("create temp dir"); let config = build_test_config(codex_home.path()).await; @@ -2934,6 +3041,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2944,15 +3052,13 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati fn turn_environments_for_tests( environment: &Arc, cwd: &codex_utils_absolute_path::AbsolutePathBuf, -) -> crate::environment_selection::ResolvedTurnEnvironments { - crate::environment_selection::ResolvedTurnEnvironments { - turn_environments: vec![TurnEnvironment { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - environment: Arc::clone(environment), - cwd: cwd.clone(), - shell: "bash".to_string(), - }], - } +) -> Vec { + vec![TurnEnvironment { + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + environment: Arc::clone(environment), + cwd: cwd.clone(), + shell: None, + }] } #[tokio::test] @@ -3459,6 +3565,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3476,6 +3583,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let result = Session::new( session_configuration, Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -3490,9 +3598,15 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { AgentControl::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, + /*state_db*/ None, Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), - /*state_db*/ None, + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), )), codex_rollout_trace::ThreadTraceContext::disabled(), ) @@ -3512,7 +3626,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let codex_home = tempfile::tempdir().expect("create temp dir"); let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); - let conversation_id = ThreadId::default(); + let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = models_manager_with_provider( config.codex_home.to_path_buf(), @@ -3565,6 +3679,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3577,7 +3692,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( - conversation_id, + thread_id, config.as_ref(), &model_info, session_configuration.session_source.clone(), @@ -3640,11 +3755,17 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { live_thread: None, thread_store: Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), - /*state_db*/ None, + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), )), model_client: ModelClient::new( Some(auth_manager.clone()), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), @@ -3661,7 +3782,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { .plugins_manager .plugins_for_config(&per_turn_config.plugins_config_input()) .await; - let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); let skill_fs = environment.get_filesystem(); @@ -3673,7 +3794,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Session::make_turn_context( - conversation_id, + thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -3685,7 +3807,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { model_info, &models_manager, /*network*/ None, - turn_environments, + crate::environment_selection::ResolvedTurnEnvironments { turn_environments }, session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, @@ -3694,7 +3816,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { - conversation_id, + conversation_id: thread_id, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -3718,14 +3841,18 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { async fn make_session_with_config( mutator: impl FnOnce(&mut Config), -) -> anyhow::Result> { - let (session, _rx_event) = make_session_with_config_and_rx(mutator).await?; - Ok(session) +) -> anyhow::Result<(Arc, tempfile::TempDir)> { + let (session, _rx_event, codex_home) = make_session_with_config_and_rx(mutator).await?; + Ok((session, codex_home)) } async fn make_session_with_config_and_rx( mutator: impl FnOnce(&mut Config), -) -> anyhow::Result<(Arc, async_channel::Receiver)> { +) -> anyhow::Result<( + Arc, + async_channel::Receiver, + tempfile::TempDir, +)> { let codex_home = tempfile::tempdir().expect("create temp dir"); let mut config = build_test_config(codex_home.path()).await; mutator(&mut config); @@ -3778,6 +3905,7 @@ async fn make_session_with_config_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3796,6 +3924,7 @@ async fn make_session_with_config_and_rx( let session = Session::new( session_configuration, Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -3810,9 +3939,123 @@ async fn make_session_with_config_and_rx( AgentControl::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, + /*state_db*/ None, Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), - /*state_db*/ None, + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), + )), + codex_rollout_trace::ThreadTraceContext::disabled(), + ) + .await?; + + Ok((session, rx_event, codex_home)) +} + +async fn make_session_with_history_source_and_agent_control_and_rx( + initial_history: InitialHistory, + session_source: SessionSource, + agent_control: AgentControl, +) -> anyhow::Result<(Arc, async_channel::Receiver)> { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let mut config = build_test_config(codex_home.path()).await; + config.ephemeral = true; + let config = Arc::new(config); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = models_manager_with_provider( + config.codex_home.to_path_buf(), + auth_manager.clone(), + config.model_provider.clone(), + ); + let model = get_model_offline_for_tests(config.model.as_deref()); + let model_info = + construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }, + }; + let default_environments = vec![TurnEnvironmentSelection { + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + cwd: config.cwd.clone(), + }]; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + approvals_reviewer: config.approvals_reviewer, + permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + environments: default_environments, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + app_server_client_version: None, + session_source: session_source.clone(), + thread_source: None, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + user_shell_override: None, + }; + + let (tx_event, rx_event) = async_channel::unbounded(); + let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); + let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); + + let session = Session::new( + session_configuration, + Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), + auth_manager, + models_manager, + Arc::new(ExecPolicyManager::default()), + tx_event, + agent_status_tx, + initial_history, + session_source, + skills_manager, + plugins_manager, + mcp_manager, + Arc::new(SkillsWatcher::noop()), + agent_control, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + /*state_db*/ None, + Arc::new(codex_thread_store::LocalThreadStore::new( + codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), )), codex_rollout_trace::ThreadTraceContext::disabled(), ) @@ -3821,6 +4064,67 @@ async fn make_session_with_config_and_rx( Ok((session, rx_event)) } +#[tokio::test] +async fn resumed_root_session_uses_thread_id_as_session_id() { + let thread_id = ThreadId::new(); + let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( + InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + SessionSource::Exec, + AgentControl::default(), + ) + .await + .expect("resume should succeed"); + + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), SessionId::from(thread_id)); + + let event = rx_event.recv().await.expect("session configured event"); + let EventMsg::SessionConfigured(event) = event.msg else { + panic!("expected session configured event"); + }; + assert_eq!(event.session_id, SessionId::from(thread_id)); + assert_eq!(event.thread_id, thread_id); +} + +#[tokio::test] +async fn resumed_subagent_session_keeps_inherited_session_id() { + let parent_thread_id = ThreadId::new(); + let parent_session_id = SessionId::from(parent_thread_id); + let thread_id = ThreadId::new(); + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( + InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + session_source, + AgentControl::default().with_session_id(parent_session_id), + ) + .await + .expect("resume should succeed"); + + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), parent_session_id); + + let event = rx_event.recv().await.expect("session configured event"); + let EventMsg::SessionConfigured(event) = event.msg else { + panic!("expected session configured event"); + }; + assert_eq!(event.session_id, parent_session_id); + assert_eq!(event.thread_id, thread_id); +} + #[tokio::test] async fn notify_request_permissions_response_ignores_unmatched_call_id() { let (session, _turn_context) = make_session_and_context().await; @@ -4480,7 +4784,7 @@ async fn default_turn_honors_empty_stored_thread_environments() { let turn_context = session.new_default_turn().await; - assert!(turn_context.environments.primary().is_none()); + assert!(turn_context.environments.primary_environment().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); assert_eq!(turn_context.cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); @@ -4499,7 +4803,7 @@ async fn primary_environment_uses_first_turn_environment() { environment_id: "second".to_string(), environment: Arc::clone(&first_environment.environment), cwd: second_cwd.clone(), - shell: first_environment.shell.clone(), + shell: None, }); assert_eq!( @@ -4542,7 +4846,7 @@ async fn empty_turn_environments_clear_primary_environment() { .await .expect("turn should start"); - assert!(turn_context.environments.primary().is_none()); + assert!(turn_context.environments.primary_environment().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); assert_eq!(turn_context.cwd, session.get_config().await.cwd); assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); @@ -4736,6 +5040,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { thread_id: session.conversation_id, forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { @@ -4998,49 +5303,15 @@ async fn make_session_and_context_with_auth_and_config_and_rx( Arc, async_channel::Receiver, ) -where - F: FnOnce(&mut Config), -{ - let codex_home = tempfile::tempdir().expect("create temp dir"); - make_session_and_context_with_auth_config_home_and_rx( - auth, - dynamic_tools, - codex_home.path(), - configure_config, - ) - .await -} - -async fn make_session_and_context_with_auth_config_home_and_rx( - auth: CodexAuth, - dynamic_tools: Vec, - codex_home: &Path, - configure_config: F, -) -> ( - Arc, - Arc, - async_channel::Receiver, -) where F: FnOnce(&mut Config), { let (tx_event, rx_event) = async_channel::unbounded(); - let mut config = build_test_config(codex_home).await; + let codex_home = tempfile::tempdir().expect("create temp dir").keep(); + let mut config = build_test_config(codex_home.as_path()).await; configure_config(&mut config); - let state_db = if config.features.enabled(Feature::Goals) { - Some( - codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), - ) - .await - .expect("goal tests should initialize sqlite state db"), - ) - } else { - None - }; let config = Arc::new(config); - let conversation_id = ThreadId::default(); + let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(auth); let models_manager = models_manager_with_provider( config.codex_home.to_path_buf(), @@ -5093,6 +5364,7 @@ where app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, @@ -5105,7 +5377,7 @@ where &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( - conversation_id, + thread_id, config.as_ref(), &model_info, session_configuration.session_source.clone(), @@ -5124,6 +5396,12 @@ where .expect("create environment"), ); + let state_db = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); let skills_watcher = Arc::new(SkillsWatcher::noop()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( @@ -5164,7 +5442,7 @@ where agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), - state_db: state_db.clone(), + state_db: Some(state_db.clone()), live_thread: None, thread_store: Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), @@ -5172,7 +5450,8 @@ where )), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), @@ -5189,7 +5468,7 @@ where .plugins_manager .plugins_for_config(&per_turn_config.plugins_config_input()) .await; - let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); let skill_fs = environment.get_filesystem(); @@ -5201,7 +5480,8 @@ where ); let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Arc::new(Session::make_turn_context( - conversation_id, + thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -5213,7 +5493,7 @@ where model_info, &models_manager, /*network*/ None, - turn_environments, + crate::environment_selection::ResolvedTurnEnvironments { turn_environments }, session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, @@ -5222,7 +5502,8 @@ where let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { - conversation_id, + conversation_id: thread_id, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -5263,13 +5544,10 @@ async fn make_goal_session_and_context_with_rx() -> ( Arc, Arc, async_channel::Receiver, - tempfile::TempDir, ) { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let (session, turn_context, rx) = make_session_and_context_with_auth_config_home_and_rx( + let (session, turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), - codex_home.path(), |config| { config .features @@ -5279,14 +5557,14 @@ async fn make_goal_session_and_context_with_rx() -> ( ) .await; upsert_goal_test_thread(session.as_ref()).await; - (session, turn_context, rx, codex_home) + (session, turn_context, rx) } async fn upsert_goal_test_thread(session: &Session) { let config = session.get_config().await; - let state_db = session - .state_db() - .expect("goal test session should have a state db"); + let state_db = goal_test_state_db(session) + .await + .expect("goal test state db should initialize"); let mut builder = codex_state::ThreadMetadataBuilder::new( session.conversation_id, config @@ -5342,7 +5620,7 @@ async fn refresh_mcp_servers_is_deferred_until_next_turn() { ); session - .refresh_mcp_servers_if_requested(&turn_context) + .refresh_mcp_servers_if_requested(&turn_context, /*elicitation_reviewer*/ None) .await; assert!(old_token.is_cancelled()); @@ -5471,6 +5749,47 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( assert!(environment_update.contains("blocked.example.com")); } +#[tokio::test] +async fn environment_context_uses_session_shell_when_environment_shell_is_absent() { + let (mut session, mut turn_context) = make_session_and_context().await; + session.services.user_shell = Arc::new(crate::shell::Shell { + shell_type: crate::shell::ShellType::PowerShell, + shell_path: PathBuf::from("powershell"), + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), + }); + for environment in &mut turn_context.environments.turn_environments { + environment.shell = None; + } + + let session_shell = session.user_shell(); + let environment_context = crate::context::EnvironmentContext::from_turn_context( + &turn_context, + session_shell.as_ref(), + ) + .render(); + assert!( + environment_context.contains("powershell"), + "{environment_context}" + ); + + let primary_environment = turn_context + .environments + .turn_environments + .first_mut() + .expect("primary environment"); + primary_environment.shell = Some("cmd".to_string()); + + let environment_context = crate::context::EnvironmentContext::from_turn_context( + &turn_context, + session_shell.as_ref(), + ) + .render(); + assert!( + environment_context.contains("cmd"), + "{environment_context}" + ); +} + #[tokio::test] async fn build_settings_update_items_emits_environment_item_for_time_changes() { let (session, previous_context) = make_session_and_context().await; @@ -5829,6 +6148,7 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget() policy: None, path_to_skills_md: test_path_buf("/tmp/admin-skill/SKILL.md").abs(), scope: SkillScope::Admin, + plugin_id: None, }, SkillMetadata { name: "repo-skill".to_string(), @@ -5839,6 +6159,7 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget() policy: None, path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), scope: SkillScope::Repo, + plugin_id: None, }, ]; turn_context.model_info.context_window = Some(100); @@ -5874,6 +6195,7 @@ fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() { policy: None, path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), scope: SkillScope::Repo, + plugin_id: None, }]; let rendered = build_available_skills( &outcome, @@ -5918,6 +6240,7 @@ fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_o policy: None, path_to_skills_md: test_path_buf("/tmp/alpha-skill/SKILL.md").abs(), scope: SkillScope::Repo, + plugin_id: None, }; let beta = SkillMetadata { name: "beta-skill".to_string(), @@ -5928,6 +6251,7 @@ fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_o policy: None, path_to_skills_md: test_path_buf("/tmp/beta-skill/SKILL.md").abs(), scope: SkillScope::Repo, + plugin_id: None, }; let minimum_skill_line_cost = |skill: &SkillMetadata| { let path = skill.path_to_skills_md.to_string_lossy().replace('\\', "/"); @@ -5975,6 +6299,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil policy: None, path_to_skills_md: test_path_buf("/tmp/admin-skill/SKILL.md").abs(), scope: SkillScope::Admin, + plugin_id: None, }, SkillMetadata { name: "repo-skill".to_string(), @@ -5985,6 +6310,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil policy: None, path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), scope: SkillScope::Repo, + plugin_id: None, }, ]; turn_context.model_info.context_window = Some(100); @@ -7103,7 +7429,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { #[tokio::test] async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), SetGoalRequest { @@ -7367,7 +7693,7 @@ async fn goal_test_state_db(sess: &Session) -> anyhow::Result anyhow::Result<()> { - let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let (sess, tc, rx) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), SetGoalRequest { @@ -7467,7 +7793,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), SetGoalRequest { @@ -7498,19 +7824,24 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a .expect("goal should remain persisted"); assert_eq!(70, goal.tokens_used); - state_db + let previous_status = goal.status; + let goal_id = goal.goal_id.clone(); + let updated_goal = state_db .update_thread_goal( sess.conversation_id, codex_state::ThreadGoalUpdate { status: Some(codex_state::ThreadGoalStatus::Complete), token_budget: None, - expected_goal_id: Some(goal.goal_id), + expected_goal_id: Some(goal_id), }, ) .await? .expect("goal status update should succeed"); sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - status: codex_state::ThreadGoalStatus::Complete, + external_set: ExternalGoalSet { + goal: updated_goal, + previous_status: ExternalGoalPreviousStatus::Existing(previous_status), + }, }) .await?; @@ -7529,7 +7860,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await; sess.spawn_task( Arc::clone(&tc), Vec::new(), @@ -7542,7 +7873,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: set_total_token_usage(&sess, post_goal_token_usage()).await; let state_db = goal_test_state_db(sess.as_ref()).await?; - state_db + let goal = state_db .replace_thread_goal( sess.conversation_id, "Keep improving the benchmark", @@ -7551,7 +7882,10 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: ) .await?; sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - status: codex_state::ThreadGoalStatus::Active, + external_set: ExternalGoalSet { + goal, + previous_status: ExternalGoalPreviousStatus::NewGoal, + }, }) .await?; @@ -7671,7 +8005,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a ) .await?; let persisted_goal = state_db - .get_thread_goal(test.session_configured.session_id) + .get_thread_goal(test.session_configured.thread_id) .await? .expect("goal should be persisted"); assert_eq!( @@ -8193,9 +8527,9 @@ async fn sample_rollout( #[tokio::test] async fn create_goal_tool_rejects_existing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = GoalHandler; + let handler = CreateGoalHandler; handler .handle(ToolInvocation { @@ -8255,11 +8589,12 @@ async fn create_goal_tool_rejects_existing_goal() { #[tokio::test] async fn update_goal_tool_rejects_pausing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = GoalHandler; + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; - handler + create_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8279,7 +8614,7 @@ async fn update_goal_tool_rejects_pausing_goal() { .await .expect("initial create_goal should succeed"); - let response = handler + let response = update_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8315,11 +8650,12 @@ async fn update_goal_tool_rejects_pausing_goal() { #[tokio::test] async fn update_goal_tool_marks_goal_complete() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = GoalHandler; + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; - handler + create_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8339,7 +8675,7 @@ async fn update_goal_tool_marks_goal_complete() { .await .expect("initial create_goal should succeed"); - handler + update_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8496,7 +8832,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() let turn_context = Arc::new(turn_context_raw); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = UnifiedExecHandler; + let handler = ExecCommandHandler; let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -8551,17 +8887,27 @@ async fn session_start_hooks_only_load_from_trusted_project_layers() -> std::io: .build() .await?; - let preview = preview_session_start_hooks(&config).await?; + let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: true, + config_layer_stack: Some(config.config_layer_stack.clone()), + ..codex_hooks::HooksConfig::default() + }); let expected_source_path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( nested_dot_codex.join("hooks.json"), )?; assert_eq!( - preview + hook_list + .hooks .iter() - .map(|run| &run.source_path) + .map(|hook| &hook.source_path) .collect::>(), vec![&expected_source_path], ); + assert_eq!( + hook_list.hooks[0].trust_status, + codex_protocol::protocol::HookTrustStatus::Untrusted + ); + assert!(preview_session_start_hooks(&config).await?.is_empty()); Ok(()) } @@ -8601,11 +8947,23 @@ async fn session_start_hooks_require_project_trust_without_config_toml() -> std: .build() .await?; + let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: true, + config_layer_stack: Some(config.config_layer_stack.clone()), + ..codex_hooks::HooksConfig::default() + }); assert_eq!( - preview_session_start_hooks(&config).await?.len(), + hook_list.hooks.len(), expected_hooks, - "unexpected hook count for {name}", + "unexpected discovered hook count for {name}", ); + assert!(preview_session_start_hooks(&config).await?.is_empty()); + if expected_hooks == 1 { + assert_eq!( + hook_list.hooks[0].trust_status, + codex_protocol::protocol::HookTrustStatus::Untrusted + ); + } } Ok(()) diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index ad7dbb1054..1026468627 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -498,7 +498,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic let turn_context = Arc::new(turn_context_raw); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = UnifiedExecHandler; + let handler = ExecCommandHandler; let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -731,11 +731,17 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let skills_watcher = Arc::new(SkillsWatcher::noop()); let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(&config), - /*state_db*/ None, + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), )); let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), @@ -747,6 +753,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { session_source: SessionSource::SubAgent(SubAgentSource::Other( GUARDIAN_REVIEWER_NAME.to_string(), )), + thread_source: None, agent_control: AgentControl::default(), dynamic_tools: Vec::new(), persist_extended_history: false, @@ -760,6 +767,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { turn_environments: Vec::new(), }, analytics_events_client: None, + state_db: None, thread_store, }) .await diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f723ba1921..e99b167d7b 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -72,8 +72,8 @@ use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; use codex_hooks::HookResult; -use codex_otel::LEGACY_NOTIFY_RUN_METRIC; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::ServiceTier; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::PlanItem; @@ -240,6 +240,7 @@ pub(crate) async fn run_turn( turn_context.as_ref(), &cancellation_token, &mentioned_skills, + Some(sess.mcp_elicitation_reviewer()), ) .await; @@ -580,13 +581,6 @@ pub(crate) async fn run_turn( }, }) .await; - if !hook_outcomes.is_empty() { - turn_context.session_telemetry.counter( - LEGACY_NOTIFY_RUN_METRIC, - /*inc*/ 1, - &[], - ); - } let mut abort_message = None; for hook_outcome in hook_outcomes { @@ -701,7 +695,11 @@ async fn track_turn_resolved_config_analytics( permission_profile_cwd: turn_context.cwd.to_path_buf(), reasoning_effort: turn_context.reasoning_effort, reasoning_summary: Some(turn_context.reasoning_summary), - service_tier: turn_context.config.service_tier, + service_tier: turn_context + .config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value), approval_policy: turn_context.approval_policy.value(), approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), @@ -1484,7 +1482,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::AgentReasoningRawContent(_) | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::SessionConfigured(_) - | EventMsg::ThreadNameUpdated(_) | EventMsg::ThreadGoalUpdated(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) @@ -1513,9 +1510,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::DeprecationNotice(_) | EventMsg::StreamError(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) @@ -1868,7 +1862,7 @@ async fn try_run_sampling_request( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, &inference_trace, ) @@ -1890,6 +1884,7 @@ async fn try_run_sampling_request( let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode); let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); let receiving_span = trace_span!("receiving_stream"); + let mut completed_response_id: Option = None; let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, @@ -2117,7 +2112,7 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id: _, + response_id, token_usage, end_turn, } => { @@ -2134,6 +2129,7 @@ async fn try_run_sampling_request( if let Some(false) = end_turn { needs_follow_up = true; } + completed_response_id = Some(response_id); break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, @@ -2245,6 +2241,15 @@ async fn try_run_sampling_request( ) .await; + if sess + .features + .enabled(Feature::ResponsesWebsocketResponseProcessed) + && outcome.is_ok() + && let Some(response_id) = completed_response_id.as_deref() + { + client_session.send_response_processed(response_id).await; + } + drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; if cancellation_token.is_cancelled() { diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index faa8d693a9..769953475e 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,13 +1,17 @@ use super::*; +use crate::SkillLoadOutcome; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_protocol::SessionId; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; +use codex_tools::ToolEnvironmentMode; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -35,7 +39,7 @@ pub(crate) struct TurnEnvironment { pub(crate) environment_id: String, pub(crate) environment: Arc, pub(crate) cwd: AbsolutePathBuf, - pub(crate) shell: String, + pub(crate) shell: Option, } impl TurnEnvironment { @@ -61,6 +65,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, + pub(crate) thread_source: Option, pub(crate) environments: ResolvedTurnEnvironments, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path @@ -118,20 +123,21 @@ impl TurnContext { ) } - pub(crate) fn effective_reasoning_effort_for_tracing(&self) -> String { + pub(crate) fn effective_reasoning_effort(&self) -> Option { if self.model_info.supports_reasoning_summaries { - match self - .reasoning_effort + self.reasoning_effort .or(self.model_info.default_reasoning_level) - { - Some(effort) => effort.to_string(), - None => "default".to_string(), - } } else { - "default".to_string() + None } } + pub(crate) fn effective_reasoning_effort_for_tracing(&self) -> String { + self.effective_reasoning_effort() + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "default".to_string()) + } + pub(crate) fn model_context_window(&self) -> Option { let effective_context_window_percent = self.model_info.effective_context_window_percent; self.model_info @@ -245,6 +251,7 @@ impl TurnContext { reasoning_effort, reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), + thread_source: self.thread_source, environments: self.environments.clone(), cwd: self.cwd.clone(), current_date: self.current_date.clone(), @@ -408,7 +415,7 @@ impl Session { per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; - per_turn_config.service_tier = session_configuration.service_tier; + per_turn_config.service_tier = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; per_turn_config.permissions.permission_profile = @@ -434,7 +441,8 @@ impl Session { #[allow(clippy::too_many_arguments)] pub(crate) fn make_turn_context( - conversation_id: ThreadId, + thread_id: ThreadId, + session_id: SessionId, auth_manager: Option>, session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, @@ -516,8 +524,9 @@ impl Session { let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( - conversation_id.to_string(), - &session_source, + session_id.to_string(), + thread_id.to_string(), + session_configuration.thread_source, sub_id.clone(), cwd.clone(), &session_configuration.permission_profile(), @@ -537,6 +546,7 @@ impl Session { reasoning_effort, reasoning_summary, session_source, + thread_source: session_configuration.thread_source, environments, cwd, current_date: Some(current_date), @@ -699,7 +709,7 @@ impl Session { .plugins_manager .plugins_for_config(&per_turn_config.plugins_config_input()) .await; - let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); let fs = primary_turn_environment .map(|turn_environment| turn_environment.environment.get_filesystem()); @@ -711,7 +721,8 @@ impl Session { ); let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( - self.conversation_id, + self.thread_id(), + self.session_id(), Some(Arc::clone(&self.services.auth_manager)), &self.services.session_telemetry, session_configuration.provider.clone(), diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 71afdba6f9..93d14ff7d6 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -232,7 +232,7 @@ async fn schedule_startup_prewarm_inner( &startup_turn_context.session_telemetry, startup_turn_context.reasoning_effort, startup_turn_context.reasoning_summary, - startup_turn_context.config.service_tier, + startup_turn_context.config.service_tier.clone(), startup_turn_metadata_header.as_deref(), ) .await?; diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index 0681d09624..2cceb2be36 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -14,6 +14,7 @@ use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; use tracing::warn; pub use codex_core_skills::SkillDependencyInfo; @@ -45,7 +46,7 @@ pub use codex_core_skills::system; pub(crate) fn skills_load_input_from_config( config: &Config, - effective_skill_roots: Vec, + effective_skill_roots: Vec, ) -> SkillsLoadInput { SkillsLoadInput::new( config.cwd.clone(), @@ -187,6 +188,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation( skill_name: candidate.name, skill_scope: candidate.scope, skill_path: candidate.path_to_skills_md.to_path_buf(), + plugin_id: candidate.plugin_id, invocation_type: InvocationType::Implicit, }; let skill_scope = match invocation.skill_scope { diff --git a/codex-rs/core/src/skills_watcher.rs b/codex-rs/core/src/skills_watcher.rs index d97b41f1d5..fb271ca876 100644 --- a/codex-rs/core/src/skills_watcher.rs +++ b/codex-rs/core/src/skills_watcher.rs @@ -63,7 +63,7 @@ impl SkillsWatcher { ) -> WatchRegistration { let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; - let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(config, effective_skill_roots); let roots = skills_manager .skill_roots_for_config(&skills_input, fs) diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 29884da4ae..fc608aed93 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -138,11 +138,8 @@ pub(crate) async fn record_completed_response_item( .await; } mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await; - let has_memory_citation = record_stage1_output_usage_and_detect_memory_citation( - sess.services.state_db.as_ref(), - item, - ) - .await; + let has_memory_citation = + record_stage1_output_usage_and_detect_memory_citation(sess.state_db(), item).await; if has_memory_citation { sess.record_memory_citation_for_turn(&turn_context.sub_id) .await; @@ -177,7 +174,7 @@ pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context( } async fn record_stage1_output_usage_and_detect_memory_citation( - state_db_ctx: Option<&state_db::StateDbHandle>, + state_db_ctx: Option, item: &ResponseItem, ) -> bool { let Some(raw_text) = raw_assistant_output_text_from_item(item) else { diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 48eec66c58..74305fb518 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -52,14 +52,14 @@ pub fn auth_manager_from_auth_with_home(auth: CodexAuth, codex_home: PathBuf) -> AuthManager::from_auth_for_testing_with_home(auth, codex_home) } -pub fn thread_manager_with_models_provider( +pub async fn thread_manager_with_models_provider( auth: CodexAuth, provider: ModelProviderInfo, ) -> ThreadManager { - ThreadManager::with_models_provider_for_tests(auth, provider) + ThreadManager::with_models_provider_for_tests(auth, provider).await } -pub fn thread_manager_with_models_provider_and_home( +pub async fn thread_manager_with_models_provider_and_home( auth: CodexAuth, provider: ModelProviderInfo, codex_home: PathBuf, @@ -71,22 +71,7 @@ pub fn thread_manager_with_models_provider_and_home( codex_home, environment_manager, ) -} - -pub fn thread_manager_with_models_provider_home_and_state( - auth: CodexAuth, - provider: ModelProviderInfo, - codex_home: PathBuf, - environment_manager: Arc, - state_db: Option, -) -> ThreadManager { - ThreadManager::with_models_provider_home_and_state_for_tests( - auth, - provider, - codex_home, - environment_manager, - state_db, - ) + .await } pub async fn start_thread_with_user_shell_override( diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d4e36ab3c8..331ed3ca15 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -7,6 +7,7 @@ use crate::environment_selection::default_thread_environment_selections; use crate::environment_selection::resolve_environment_selections; use crate::file_watcher::FileWatcher; use crate::mcp::McpManager; +use crate::resolve_installation_id; use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::session::Codex; @@ -18,6 +19,8 @@ use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; +use codex_agent_graph_store::AgentGraphStore; +use codex_agent_graph_store::LocalAgentGraphStore; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; @@ -40,18 +43,18 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; -use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; +use codex_rollout::state_db; use codex_rollout::state_db::StateDbHandle; -use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_thread_store::InMemoryThreadStore; use codex_thread_store::LocalThreadStore; use codex_thread_store::LocalThreadStoreConfig; @@ -217,6 +220,7 @@ pub struct StartThreadOptions { pub config: Config, pub initial_history: InitialHistory, pub session_source: Option, + pub thread_source: Option, pub dynamic_tools: Vec, pub persist_extended_history: bool, pub metrics_service_name: Option, @@ -247,9 +251,11 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, thread_store: Arc, + state_db: StateDbHandle, + agent_graph_store: Arc, session_source: SessionSource, + installation_id: String, analytics_events_client: Option, - state_db: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -265,10 +271,11 @@ pub fn build_models_manager( ) } -pub fn thread_store_from_config( - config: &Config, - state_db: Option, -) -> Arc { +pub async fn init_state_db_from_config(config: &Config) -> Option { + state_db::init(config).await +} + +pub fn thread_store_from_config(config: &Config, state_db: StateDbHandle) -> Arc { match &config.experimental_thread_store { ThreadStoreConfig::Local => Arc::new(LocalThreadStore::new( LocalThreadStoreConfig::from_config(config), @@ -279,15 +286,39 @@ pub fn thread_store_from_config( } } +pub fn agent_graph_store_from_state_db(state_db: StateDbHandle) -> Arc { + Arc::new(LocalAgentGraphStore::new(state_db)) +} + +async fn state_db_from_roots_for_tests( + codex_home: PathBuf, + sqlite_home: PathBuf, + default_model_provider_id: String, +) -> StateDbHandle { + let config = codex_rollout::RolloutConfig { + codex_home: codex_home.clone(), + sqlite_home, + cwd: codex_home, + model_provider_id: default_model_provider_id, + generate_memories: false, + }; + state_db::try_init(&config) + .await + .unwrap_or_else(|err| panic!("test state db should initialize: {err}")) +} + impl ThreadManager { + #[allow(clippy::too_many_arguments)] pub fn new( config: &Config, auth_manager: Arc, session_source: SessionSource, environment_manager: Arc, analytics_events_client: Option, + state_db: StateDbHandle, thread_store: Arc, - state_db: Option, + agent_graph_store: Arc, + installation_id: String, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -314,10 +345,12 @@ impl ThreadManager { mcp_manager, skills_watcher, thread_store, + state_db, + agent_graph_store, auth_manager, session_source, + installation_id, analytics_events_client, - state_db, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -327,7 +360,7 @@ impl ThreadManager { /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. - pub(crate) fn with_models_provider_for_tests( + pub(crate) async fn with_models_provider_for_tests( auth: CodexAuth, provider: ModelProviderInfo, ) -> Self { @@ -338,11 +371,27 @@ impl ThreadManager { )); std::fs::create_dir_all(&codex_home) .unwrap_or_else(|err| panic!("temp codex home dir create failed: {err}")); - let mut manager = Self::with_models_provider_and_home_for_tests( + let state_db = state_db_from_roots_for_tests( + codex_home.clone(), + codex_home.clone(), + OPENAI_PROVIDER_ID.to_string(), + ) + .await; + let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { + Ok(codex_home) => codex_home, + Err(err) => panic!("test codex_home should be absolute: {err}"), + }; + let installation_id = resolve_installation_id(&skills_codex_home) + .await + .unwrap_or_else(|err| panic!("resolve test installation id failed: {err}")); + let mut manager = Self::with_models_provider_and_home_and_state_db_for_tests( auth, provider, codex_home.clone(), Arc::new(EnvironmentManager::default_for_tests()), + state_db, + skills_codex_home, + installation_id, ); manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home }); manager @@ -350,34 +399,47 @@ impl ThreadManager { /// Construct with a dummy AuthManager containing the provided CodexAuth and codex home. /// Used for integration tests: should not be used by ordinary business logic. - pub(crate) fn with_models_provider_and_home_for_tests( + pub(crate) async fn with_models_provider_and_home_for_tests( auth: CodexAuth, provider: ModelProviderInfo, codex_home: PathBuf, environment_manager: Arc, ) -> Self { - Self::with_models_provider_home_and_state_for_tests( - auth, - provider, - codex_home, - environment_manager, - /*state_db*/ None, + let state_db = state_db_from_roots_for_tests( + codex_home.clone(), + codex_home.clone(), + OPENAI_PROVIDER_ID.to_string(), ) - } - - pub(crate) fn with_models_provider_home_and_state_for_tests( - auth: CodexAuth, - provider: ModelProviderInfo, - codex_home: PathBuf, - environment_manager: Arc, - state_db: Option, - ) -> Self { - set_thread_manager_test_mode_for_tests(/*enabled*/ true); - let auth_manager = AuthManager::from_auth_for_testing(auth); + .await; let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { Ok(codex_home) => codex_home, Err(err) => panic!("test codex_home should be absolute: {err}"), }; + let installation_id = resolve_installation_id(&skills_codex_home) + .await + .unwrap_or_else(|err| panic!("resolve test installation id failed: {err}")); + Self::with_models_provider_and_home_and_state_db_for_tests( + auth, + provider, + codex_home, + environment_manager, + state_db, + skills_codex_home, + installation_id, + ) + } + + fn with_models_provider_and_home_and_state_db_for_tests( + auth: CodexAuth, + provider: ModelProviderInfo, + codex_home: PathBuf, + environment_manager: Arc, + state_db: StateDbHandle, + skills_codex_home: AbsolutePathBuf, + installation_id: String, + ) -> Self { + set_thread_manager_test_mode_for_tests(/*enabled*/ true); + let auth_manager = AuthManager::from_auth_for_testing(auth); let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); let restriction_product = SessionSource::Exec.restriction_product(); let plugins_manager = Arc::new(PluginsManager::new_with_restriction_product( @@ -396,11 +458,11 @@ impl ThreadManager { let thread_store: Arc = Arc::new(LocalThreadStore::new( LocalThreadStoreConfig { codex_home: codex_home.clone(), - sqlite_home: codex_home.clone(), default_model_provider_id: OPENAI_PROVIDER_ID.to_string(), }, state_db.clone(), )); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); Self { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), @@ -413,10 +475,12 @@ impl ThreadManager { mcp_manager, skills_watcher, thread_store, + state_db, + agent_graph_store, auth_manager, session_source: SessionSource::Exec, + installation_id, analytics_events_client: None, - state_db, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -482,27 +546,6 @@ impl ThreadManager { self.state.list_thread_ids().await } - pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) { - let threads = self - .state - .threads - .read() - .await - .values() - .cloned() - .collect::>(); - for thread in threads { - if let Err(err) = thread - .submit(Op::RefreshMcpServers { - config: refresh_config.clone(), - }) - .await - { - warn!("failed to request MCP server refresh: {err}"); - } - } - } - pub fn subscribe_thread_created(&self) -> broadcast::Receiver { self.state.thread_created_tx.subscribe() } @@ -523,22 +566,17 @@ impl ThreadManager { subtree_thread_ids.push(thread_id); seen_thread_ids.insert(thread_id); - if let Some(state_db_ctx) = thread.state_db() { - for status in [ - DirectionalThreadSpawnEdgeStatus::Open, - DirectionalThreadSpawnEdgeStatus::Closed, - ] { - for descendant_id in state_db_ctx - .list_thread_spawn_descendants_with_status(thread_id, status) - .await - .map_err(|err| { - CodexErr::Fatal(format!("failed to load thread-spawn descendants: {err}")) - })? - { - if seen_thread_ids.insert(descendant_id) { - subtree_thread_ids.push(descendant_id); - } - } + for descendant_id in self + .state + .agent_graph_store + .list_thread_spawn_descendants(thread_id, /*status_filter*/ None) + .await + .map_err(|err| { + CodexErr::Fatal(format!("failed to load thread-spawn descendants: {err}")) + })? + { + if seen_thread_ids.insert(descendant_id) { + subtree_thread_ids.push(descendant_id); } } @@ -583,6 +621,7 @@ impl ThreadManager { config, initial_history: InitialHistory::New, session_source: None, + thread_source: None, dynamic_tools, persist_extended_history, metrics_service_name: None, @@ -599,12 +638,16 @@ impl ThreadManager { let session_source = options .session_source .unwrap_or_else(|| self.state.session_source.clone()); + let thread_source = options + .thread_source + .or_else(|| options.initial_history.get_resumed_thread_source()); Box::pin(self.state.spawn_thread_with_source( options.config, options.initial_history, Arc::clone(&self.state.auth_manager), self.agent_control(), session_source, + thread_source, options.dynamic_tools, options.persist_extended_history, options.metrics_service_name, @@ -647,11 +690,13 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); + let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.state.spawn_thread( config, initial_history, auth_manager, self.agent_control(), + thread_source, Vec::new(), persist_extended_history, /*metrics_service_name*/ None, @@ -676,6 +721,7 @@ impl ThreadManager { InitialHistory::New, Arc::clone(&self.state.auth_manager), self.agent_control(), + /*thread_source*/ None, Vec::new(), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -698,11 +744,13 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); + let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.state.spawn_thread( config, initial_history, auth_manager, self.agent_control(), + thread_source, Vec::new(), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -780,6 +828,7 @@ impl ThreadManager { snapshot: S, config: Config, path: PathBuf, + thread_source: Option, persist_extended_history: bool, parent_trace: Option, ) -> CodexResult @@ -792,6 +841,7 @@ impl ThreadManager { snapshot, config, history, + thread_source, persist_extended_history, parent_trace, ) @@ -804,6 +854,7 @@ impl ThreadManager { snapshot: S, config: Config, history: InitialHistory, + thread_source: Option, persist_extended_history: bool, parent_trace: Option, ) -> CodexResult @@ -814,6 +865,7 @@ impl ThreadManager { snapshot.into(), config, history, + thread_source, persist_extended_history, parent_trace, ) @@ -825,6 +877,7 @@ impl ThreadManager { snapshot: ForkSnapshot, config: Config, history: InitialHistory, + thread_source: Option, persist_extended_history: bool, parent_trace: Option, ) -> CodexResult { @@ -839,6 +892,7 @@ impl ThreadManager { history, Arc::clone(&self.state.auth_manager), self.agent_control(), + thread_source, Vec::new(), persist_extended_history, /*metrics_service_name*/ None, @@ -864,10 +918,14 @@ impl ThreadManager { } impl ThreadManagerState { - pub(crate) fn state_db(&self) -> Option { + pub(crate) fn state_db(&self) -> StateDbHandle { self.state_db.clone() } + pub(crate) fn agent_graph_store(&self) -> Arc { + self.agent_graph_store.clone() + } + pub(crate) async fn list_thread_ids(&self) -> Vec { self.threads .read() @@ -950,6 +1008,7 @@ impl ThreadManagerState { config, agent_control, self.session_source.clone(), + /*thread_source*/ None, /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*inherited_shell_snapshot*/ None, @@ -965,6 +1024,7 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, session_source: SessionSource, + thread_source: Option, persist_extended_history: bool, metrics_service_name: Option, inherited_shell_snapshot: Option>, @@ -980,6 +1040,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + thread_source, Vec::new(), persist_extended_history, metrics_service_name, @@ -1006,12 +1067,14 @@ impl ThreadManagerState { } = options; let environments = default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); + let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.spawn_thread_with_source( config, initial_history, Arc::clone(&self.auth_manager), agent_control, session_source, + thread_source, Vec::new(), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -1031,6 +1094,7 @@ impl ThreadManagerState { initial_history: InitialHistory, agent_control: AgentControl, session_source: SessionSource, + thread_source: Option, persist_extended_history: bool, inherited_shell_snapshot: Option>, inherited_exec_policy: Option>, @@ -1045,6 +1109,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + thread_source, Vec::new(), persist_extended_history, /*metrics_service_name*/ None, @@ -1065,6 +1130,7 @@ impl ThreadManagerState { initial_history: InitialHistory, auth_manager: Arc, agent_control: AgentControl, + thread_source: Option, dynamic_tools: Vec, persist_extended_history: bool, metrics_service_name: Option, @@ -1078,6 +1144,7 @@ impl ThreadManagerState { auth_manager, agent_control, self.session_source.clone(), + thread_source, dynamic_tools, persist_extended_history, metrics_service_name, @@ -1098,6 +1165,7 @@ impl ThreadManagerState { auth_manager: Arc, agent_control: AgentControl, session_source: SessionSource, + thread_source: Option, dynamic_tools: Vec, persist_extended_history: bool, metrics_service_name: Option, @@ -1152,6 +1220,7 @@ impl ThreadManagerState { codex, thread_id, .. } = Codex::spawn(CodexSpawnArgs { config, + installation_id: self.installation_id.clone(), auth_manager, models_manager: Arc::clone(&self.models_manager), environment_manager: Arc::clone(&self.environment_manager), @@ -1161,6 +1230,7 @@ impl ThreadManagerState { skills_watcher: Arc::clone(&self.skills_watcher), conversation_history: initial_history, session_source, + thread_source, agent_control, dynamic_tools, persist_extended_history, @@ -1172,6 +1242,7 @@ impl ThreadManagerState { parent_trace, environment_selections, analytics_events_client: self.analytics_events_client.clone(), + state_db: Some(self.state_db.clone()), thread_store: Arc::clone(&self.thread_store), }) .await?; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d50a3ae8f9..0f6afa05a6 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::config::test_config; -use crate::init_state_db; +use crate::installation_id::INSTALLATION_ID_FILENAME; use crate::rollout::RolloutRecorder; use crate::session::session::SessionSettingsUpdate; use crate::session::tests::make_session_and_context; @@ -16,6 +16,7 @@ use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use core_test_support::PathBufExt; @@ -26,6 +27,8 @@ use std::time::Duration; use tempfile::tempdir; use wiremock::MockServer; +const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -47,6 +50,21 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +async fn state_backed_stores( + config: &Config, +) -> ( + StateDbHandle, + Arc, + Arc, +) { + let state_db = init_state_db_from_config(config) + .await + .expect("thread manager test requires state db"); + let thread_store = thread_store_from_config(config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + (state_db, thread_store, agent_graph_store) +} + fn contextual_user_interrupted_marker() -> ResponseItem { interrupted_turn_history_marker(InterruptedTurnHistoryMarker::ContextualUser) .expect("contextual-user interrupted marker should be enabled") @@ -164,6 +182,7 @@ fn fork_thread_accepts_legacy_usize_snapshot_argument() { usize::MAX, config, path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ); @@ -261,7 +280,8 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { config.model_provider.clone(), config.codex_home.to_path_buf(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); + ) + .await; let thread_1 = manager .start_thread(config.clone()) .await @@ -310,13 +330,15 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d config.model_provider.clone(), config.codex_home.to_path_buf(), environment_manager, - ); + ) + .await; let thread = manager .start_thread_with_options(StartThreadOptions { config: config.clone(), initial_history: InitialHistory::New, session_source: None, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -345,7 +367,8 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { config.model_provider.clone(), config.codex_home.to_path_buf(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - ); + ) + .await; let thread = manager .start_thread_with_options(StartThreadOptions { config, @@ -353,6 +376,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { session_source: Some(SessionSource::Internal( InternalSessionSource::MemoryConsolidation, )), + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -384,14 +408,17 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, /*state_db*/ None), - /*state_db*/ None, + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let selected_cwd = AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path"); @@ -405,6 +432,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { config: config.clone(), initial_history: InitialHistory::New, session_source: None, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -461,6 +489,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { ForkSnapshot::Interrupted, config, rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -484,6 +513,46 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { ); } +#[tokio::test] +async fn explicit_installation_id_skips_codex_home_file() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let installation_id = uuid::Uuid::new_v4().to_string(); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; + let manager = ThreadManager::new( + &config, + auth_manager, + SessionSource::Exec, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + installation_id.clone(), + ); + + let thread = manager + .start_thread(config.clone()) + .await + .expect("start thread with explicit installation id"); + + assert!(!config.codex_home.join(INSTALLATION_ID_FILENAME).exists()); + assert_eq!(thread.thread.codex.session.installation_id, installation_id); + + thread + .thread + .shutdown_and_wait() + .await + .expect("shutdown thread"); + let _ = manager.remove_thread(&thread.thread_id).await; +} + #[tokio::test] async fn resume_active_thread_from_rollout_returns_running_thread() { let temp_dir = tempdir().expect("tempdir"); @@ -494,14 +563,17 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, /*state_db*/ None), - /*state_db*/ None, + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -548,14 +620,17 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, /*state_db*/ None), - /*state_db*/ None, + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -597,6 +672,87 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { .expect("shutdown resumed thread"); } +#[tokio::test] +async fn resume_stopped_thread_from_rollout_preserves_thread_source() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), + ); + + let source = manager + .start_thread_with_options(StartThreadOptions { + config: config.clone(), + initial_history: InitialHistory::New, + session_source: None, + thread_source: Some(ThreadSource::User), + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + parent_trace: None, + environments: Vec::new(), + }) + .await + .expect("start source thread"); + source.thread.ensure_rollout_materialized().await; + source + .thread + .flush_rollout() + .await + .expect("flush source rollout"); + let rollout_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + source + .thread + .shutdown_and_wait() + .await + .expect("shutdown source thread before resume"); + let _ = manager.remove_thread(&source.thread_id).await; + + let resumed = manager + .resume_thread_from_rollout( + config, + rollout_path, + auth_manager, + /*parent_trace*/ None, + ) + .await + .expect("resume source thread"); + + assert_eq!( + resumed + .thread + .config_snapshot() + .await + .thread_source + .as_ref(), + Some(&ThreadSource::User) + ); + + resumed + .thread + .shutdown_and_wait() + .await + .expect("shutdown resumed thread"); +} + #[tokio::test] async fn new_uses_active_provider_for_model_refresh() { let server = MockServer::start().await; @@ -612,14 +768,17 @@ async fn new_uses_active_provider_for_model_refresh() { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager, SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, /*state_db*/ None), - /*state_db*/ None, + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -824,15 +983,17 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let state_db = init_state_db(&config).await; + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -865,6 +1026,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor ForkSnapshot::Interrupted, config.clone(), source_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -928,15 +1090,17 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let state_db = init_state_db(&config).await; + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -980,6 +1144,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { ForkSnapshot::Interrupted, config.clone(), source_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -1021,15 +1186,17 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let state_db = init_state_db(&config).await; + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1060,6 +1227,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ForkSnapshot::Interrupted, config.clone(), source_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -1100,6 +1268,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ForkSnapshot::Interrupted, config.clone(), forked_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -1159,15 +1328,17 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let state_db = init_state_db(&config).await; + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), + state_db, + thread_store, + agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1236,6 +1407,11 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { .await .is_none() ); + let goal = state_db + .get_thread_goal(resumed.thread_id) + .await? + .expect("goal should still exist after resume"); + assert_eq!(codex_state::ThreadGoalStatus::Paused, goal.status); resumed.thread.shutdown_and_wait().await?; Ok(()) diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 6b99e09b56..42841d2189 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -4,6 +4,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; use super::ExecContext; use super::PUBLIC_TOOL_NAME; @@ -78,6 +79,10 @@ impl CodeModeExecuteHandler { impl ToolHandler for CodeModeExecuteHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(PUBLIC_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 70fa51251a..8024c95865 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; use super::DEFAULT_WAIT_YIELD_TIME_MS; use super::ExecContext; @@ -41,6 +42,10 @@ where impl ToolHandler for CodeModeWaitHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(WAIT_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index b3d9b481de..4c3cd34c2e 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -6,13 +6,8 @@ use crate::config::Config; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; use crate::tools::handlers::multi_agents::build_agent_spawn_config; use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::protocol::AgentStatus; @@ -35,7 +30,11 @@ use tokio::time::Instant; use tokio::time::timeout; use uuid::Uuid; -pub struct BatchJobHandler; +mod report_agent_job_result; +mod spawn_agents_on_csv; + +pub use report_agent_job_result::ReportAgentJobResultHandler; +pub use spawn_agents_on_csv::SpawnAgentsOnCsvHandler; const DEFAULT_AGENT_JOB_CONCURRENCY: usize = 16; const MAX_AGENT_JOB_CONCURRENCY: usize = 64; @@ -99,334 +98,6 @@ struct ActiveJobItem { status_rx: Option>, } -impl ToolHandler for BatchJobHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tool_name, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "agent jobs handler received unsupported payload".to_string(), - )); - } - }; - - match tool_name.name.as_str() { - "spawn_agents_on_csv" => spawn_agents_on_csv::handle(session, turn, arguments).await, - "report_agent_job_result" => report_agent_job_result::handle(session, arguments).await, - other => Err(FunctionCallError::RespondToModel(format!( - "unsupported agent job tool {other}" - ))), - } - } -} - -mod spawn_agents_on_csv { - use super::*; - - /// Create a new agent job from a CSV and run it to completion. - /// - /// Each CSV row becomes a job item. The instruction string is a template where `{column}` - /// placeholders are filled with values from that row. Results are reported by workers via - /// `report_agent_job_result`, then exported to CSV on completion. - pub async fn handle( - session: Arc, - turn: Arc, - arguments: String, - ) -> Result { - let args: SpawnAgentsOnCsvArgs = parse_arguments(arguments.as_str())?; - if args.instruction.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "instruction must be non-empty".to_string(), - )); - } - - let db = required_state_db(&session)?; - let input_path = turn.resolve_path(Some(args.csv_path)); - let input_path_display = input_path.display().to_string(); - let csv_content = tokio::fs::read_to_string(&input_path) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to read csv input {input_path_display}: {err}" - )) - })?; - let (headers, rows) = parse_csv(csv_content.as_str()).map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to parse csv input: {err}")) - })?; - if headers.is_empty() { - return Err(FunctionCallError::RespondToModel( - "csv input must include a header row".to_string(), - )); - } - ensure_unique_headers(headers.as_slice())?; - - let id_column_index = args.id_column.as_ref().map_or(Ok(None), |column_name| { - headers - .iter() - .position(|header| header == column_name) - .map(Some) - .ok_or_else(|| { - FunctionCallError::RespondToModel(format!( - "id_column {column_name} was not found in csv headers" - )) - }) - })?; - - let mut items = Vec::with_capacity(rows.len()); - let mut seen_ids = HashSet::new(); - for (idx, row) in rows.into_iter().enumerate() { - if row.len() != headers.len() { - let row_index = idx + 2; - let row_len = row.len(); - let header_len = headers.len(); - return Err(FunctionCallError::RespondToModel(format!( - "csv row {row_index} has {row_len} fields but header has {header_len}" - ))); - } - - let source_id = id_column_index - .and_then(|index| row.get(index).cloned()) - .filter(|value| !value.trim().is_empty()); - let row_index = idx + 1; - let base_item_id = source_id - .clone() - .unwrap_or_else(|| format!("row-{row_index}")); - let mut item_id = base_item_id.clone(); - let mut suffix = 2usize; - while !seen_ids.insert(item_id.clone()) { - item_id = format!("{base_item_id}-{suffix}"); - suffix = suffix.saturating_add(1); - } - - let row_object = headers - .iter() - .zip(row.iter()) - .map(|(header, value)| (header.clone(), Value::String(value.clone()))) - .collect::>(); - items.push(codex_state::AgentJobItemCreateParams { - item_id, - row_index: idx as i64, - source_id, - row_json: Value::Object(row_object), - }); - } - - let job_id = Uuid::new_v4().to_string(); - let output_csv_path = args.output_csv_path.map_or_else( - || default_output_csv_path(&input_path, job_id.as_str()), - |path| turn.resolve_path(Some(path)), - ); - let job_suffix = &job_id[..8]; - let job_name = format!("agent-job-{job_suffix}"); - let max_runtime_seconds = normalize_max_runtime_seconds( - args.max_runtime_seconds - .or(turn.config.agent_job_max_runtime_seconds), - )?; - let _job = db - .create_agent_job( - &codex_state::AgentJobCreateParams { - id: job_id.clone(), - name: job_name, - instruction: args.instruction, - auto_export: true, - max_runtime_seconds, - output_schema_json: args.output_schema, - input_headers: headers, - input_csv_path: input_path.display().to_string(), - output_csv_path: output_csv_path.display().to_string(), - }, - items.as_slice(), - ) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to create agent job: {err}")) - })?; - - let requested_concurrency = args.max_concurrency.or(args.max_workers); - let options = match build_runner_options(&session, &turn, requested_concurrency).await { - Ok(options) => options, - Err(err) => { - let error_message = err.to_string(); - let _ = db - .mark_agent_job_failed(job_id.as_str(), error_message.as_str()) - .await; - return Err(err); - } - }; - db.mark_agent_job_running(job_id.as_str()) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to transition agent job {job_id} to running: {err}" - )) - })?; - if let Err(err) = run_agent_job_loop( - session.clone(), - turn.clone(), - db.clone(), - job_id.clone(), - options, - ) - .await - { - let error_message = format!("job runner failed: {err}"); - let _ = db - .mark_agent_job_failed(job_id.as_str(), error_message.as_str()) - .await; - return Err(FunctionCallError::RespondToModel(format!( - "agent job {job_id} failed: {err}" - ))); - } - - let job = db - .get_agent_job(job_id.as_str()) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to load agent job {job_id}: {err}" - )) - })? - .ok_or_else(|| { - FunctionCallError::RespondToModel(format!("agent job {job_id} not found")) - })?; - let output_path = PathBuf::from(job.output_csv_path.clone()); - if !tokio::fs::try_exists(&output_path).await.unwrap_or(false) { - export_job_csv_snapshot(db.clone(), &job) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to export output csv {job_id}: {err}" - )) - })?; - } - let progress = db - .get_agent_job_progress(job_id.as_str()) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to load agent job progress {job_id}: {err}" - )) - })?; - let mut job_error = job.last_error.clone().filter(|err| !err.trim().is_empty()); - let failed_item_errors = if progress.failed_items > 0 { - let items = db - .list_agent_job_items( - job_id.as_str(), - Some(codex_state::AgentJobItemStatus::Failed), - Some(5), - ) - .await - .unwrap_or_default(); - let summaries: Vec<_> = items - .into_iter() - .filter_map(|item| { - let last_error = item.last_error.unwrap_or_default(); - if last_error.trim().is_empty() { - return None; - } - Some(AgentJobFailureSummary { - item_id: item.item_id, - source_id: item.source_id, - last_error, - }) - }) - .collect(); - if summaries.is_empty() { - if job_error.is_none() { - job_error = Some( - "agent job has failed items but no error details were recorded".to_string(), - ); - } - None - } else { - Some(summaries) - } - } else { - None - }; - let content = serde_json::to_string(&SpawnAgentsOnCsvResult { - job_id, - status: job.status.as_str().to_string(), - output_csv_path: job.output_csv_path, - total_items: progress.total_items, - completed_items: progress.completed_items, - failed_items: progress.failed_items, - job_error, - failed_item_errors, - }) - .map_err(|err| { - FunctionCallError::Fatal(format!( - "failed to serialize spawn_agents_on_csv result: {err}" - )) - })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) - } -} - -mod report_agent_job_result { - use super::*; - - pub async fn handle( - session: Arc, - arguments: String, - ) -> Result { - let args: ReportAgentJobResultArgs = parse_arguments(arguments.as_str())?; - if !args.result.is_object() { - return Err(FunctionCallError::RespondToModel( - "result must be a JSON object".to_string(), - )); - } - let db = required_state_db(&session)?; - let reporting_thread_id = session.conversation_id.to_string(); - let accepted = db - .report_agent_job_item_result( - args.job_id.as_str(), - args.item_id.as_str(), - reporting_thread_id.as_str(), - &args.result, - ) - .await - .map_err(|err| { - let job_id = args.job_id.as_str(); - let item_id = args.item_id.as_str(); - FunctionCallError::RespondToModel(format!( - "failed to record agent job result for {job_id} / {item_id}: {err}" - )) - })?; - if accepted && args.stop.unwrap_or(false) { - let message = "cancelled by worker request"; - let _ = db - .mark_agent_job_cancelled(args.job_id.as_str(), message) - .await; - } - let content = - serde_json::to_string(&ReportAgentJobResultToolResult { accepted }).map_err(|err| { - FunctionCallError::Fatal(format!( - "failed to serialize report_agent_job_result result: {err}" - )) - })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) - } -} - fn required_state_db( session: &Arc, ) -> Result, FunctionCallError> { diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs new file mode 100644 index 0000000000..90cde7d446 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs @@ -0,0 +1,86 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_tools::ToolName; + +use super::*; + +pub struct ReportAgentJobResultHandler; + +impl ToolHandler for ReportAgentJobResultHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("report_agent_job_result") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, payload, .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "report_agent_job_result handler received unsupported payload".to_string(), + )); + } + }; + + handle(session, arguments).await + } +} + +pub async fn handle( + session: Arc, + arguments: String, +) -> Result { + let args: ReportAgentJobResultArgs = parse_arguments(arguments.as_str())?; + if !args.result.is_object() { + return Err(FunctionCallError::RespondToModel( + "result must be a JSON object".to_string(), + )); + } + let db = required_state_db(&session)?; + let reporting_thread_id = session.conversation_id.to_string(); + let accepted = db + .report_agent_job_item_result( + args.job_id.as_str(), + args.item_id.as_str(), + reporting_thread_id.as_str(), + &args.result, + ) + .await + .map_err(|err| { + let job_id = args.job_id.as_str(); + let item_id = args.item_id.as_str(); + FunctionCallError::RespondToModel(format!( + "failed to record agent job result for {job_id} / {item_id}: {err}" + )) + })?; + if accepted && args.stop.unwrap_or(false) { + let message = "cancelled by worker request"; + let _ = db + .mark_agent_job_cancelled(args.job_id.as_str(), message) + .await; + } + let content = + serde_json::to_string(&ReportAgentJobResultToolResult { accepted }).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize report_agent_job_result result: {err}" + )) + })?; + Ok(FunctionToolOutput::from_text(content, Some(true))) +} diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs new file mode 100644 index 0000000000..911f1a5eef --- /dev/null +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -0,0 +1,284 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_tools::ToolName; + +use super::*; + +pub struct SpawnAgentsOnCsvHandler; + +impl ToolHandler for SpawnAgentsOnCsvHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("spawn_agents_on_csv") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "agent jobs handler received unsupported payload".to_string(), + )); + } + }; + + handle(session, turn, arguments).await + } +} + +/// Create a new agent job from a CSV and run it to completion. +/// +/// Each CSV row becomes a job item. The instruction string is a template where `{column}` +/// placeholders are filled with values from that row. Results are reported by workers via +/// `report_agent_job_result`, then exported to CSV on completion. +pub async fn handle( + session: Arc, + turn: Arc, + arguments: String, +) -> Result { + let args: SpawnAgentsOnCsvArgs = parse_arguments(arguments.as_str())?; + if args.instruction.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "instruction must be non-empty".to_string(), + )); + } + + let db = required_state_db(&session)?; + let input_path = turn.resolve_path(Some(args.csv_path)); + let input_path_display = input_path.display().to_string(); + let csv_content = tokio::fs::read_to_string(&input_path) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to read csv input {input_path_display}: {err}" + )) + })?; + let (headers, rows) = parse_csv(csv_content.as_str()).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse csv input: {err}")) + })?; + if headers.is_empty() { + return Err(FunctionCallError::RespondToModel( + "csv input must include a header row".to_string(), + )); + } + ensure_unique_headers(headers.as_slice())?; + + let id_column_index = args.id_column.as_ref().map_or(Ok(None), |column_name| { + headers + .iter() + .position(|header| header == column_name) + .map(Some) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "id_column {column_name} was not found in csv headers" + )) + }) + })?; + + let mut items = Vec::with_capacity(rows.len()); + let mut seen_ids = HashSet::new(); + for (idx, row) in rows.into_iter().enumerate() { + if row.len() != headers.len() { + let row_index = idx + 2; + let row_len = row.len(); + let header_len = headers.len(); + return Err(FunctionCallError::RespondToModel(format!( + "csv row {row_index} has {row_len} fields but header has {header_len}" + ))); + } + + let source_id = id_column_index + .and_then(|index| row.get(index).cloned()) + .filter(|value| !value.trim().is_empty()); + let row_index = idx + 1; + let base_item_id = source_id + .clone() + .unwrap_or_else(|| format!("row-{row_index}")); + let mut item_id = base_item_id.clone(); + let mut suffix = 2usize; + while !seen_ids.insert(item_id.clone()) { + item_id = format!("{base_item_id}-{suffix}"); + suffix = suffix.saturating_add(1); + } + + let row_object = headers + .iter() + .zip(row.iter()) + .map(|(header, value)| (header.clone(), Value::String(value.clone()))) + .collect::>(); + items.push(codex_state::AgentJobItemCreateParams { + item_id, + row_index: idx as i64, + source_id, + row_json: Value::Object(row_object), + }); + } + + let job_id = Uuid::new_v4().to_string(); + let output_csv_path = args.output_csv_path.map_or_else( + || default_output_csv_path(&input_path, job_id.as_str()), + |path| turn.resolve_path(Some(path)), + ); + let job_suffix = &job_id[..8]; + let job_name = format!("agent-job-{job_suffix}"); + let max_runtime_seconds = normalize_max_runtime_seconds( + args.max_runtime_seconds + .or(turn.config.agent_job_max_runtime_seconds), + )?; + let _job = db + .create_agent_job( + &codex_state::AgentJobCreateParams { + id: job_id.clone(), + name: job_name, + instruction: args.instruction, + auto_export: true, + max_runtime_seconds, + output_schema_json: args.output_schema, + input_headers: headers, + input_csv_path: input_path.display().to_string(), + output_csv_path: output_csv_path.display().to_string(), + }, + items.as_slice(), + ) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to create agent job: {err}")) + })?; + + let requested_concurrency = args.max_concurrency.or(args.max_workers); + let options = match build_runner_options(&session, &turn, requested_concurrency).await { + Ok(options) => options, + Err(err) => { + let error_message = err.to_string(); + let _ = db + .mark_agent_job_failed(job_id.as_str(), error_message.as_str()) + .await; + return Err(err); + } + }; + db.mark_agent_job_running(job_id.as_str()) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to transition agent job {job_id} to running: {err}" + )) + })?; + if let Err(err) = run_agent_job_loop( + session.clone(), + turn.clone(), + db.clone(), + job_id.clone(), + options, + ) + .await + { + let error_message = format!("job runner failed: {err}"); + let _ = db + .mark_agent_job_failed(job_id.as_str(), error_message.as_str()) + .await; + return Err(FunctionCallError::RespondToModel(format!( + "agent job {job_id} failed: {err}" + ))); + } + + let job = db + .get_agent_job(job_id.as_str()) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to load agent job {job_id}: {err}")) + })? + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!("agent job {job_id} not found")) + })?; + let output_path = PathBuf::from(job.output_csv_path.clone()); + if !tokio::fs::try_exists(&output_path).await.unwrap_or(false) { + export_job_csv_snapshot(db.clone(), &job) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to export output csv {job_id}: {err}" + )) + })?; + } + let progress = db + .get_agent_job_progress(job_id.as_str()) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to load agent job progress {job_id}: {err}" + )) + })?; + let mut job_error = job.last_error.clone().filter(|err| !err.trim().is_empty()); + let failed_item_errors = if progress.failed_items > 0 { + let items = db + .list_agent_job_items( + job_id.as_str(), + Some(codex_state::AgentJobItemStatus::Failed), + Some(5), + ) + .await + .unwrap_or_default(); + let summaries: Vec<_> = items + .into_iter() + .filter_map(|item| { + let last_error = item.last_error.unwrap_or_default(); + if last_error.trim().is_empty() { + return None; + } + Some(AgentJobFailureSummary { + item_id: item.item_id, + source_id: item.source_id, + last_error, + }) + }) + .collect(); + if summaries.is_empty() { + if job_error.is_none() { + job_error = Some( + "agent job has failed items but no error details were recorded".to_string(), + ); + } + None + } else { + Some(summaries) + } + } else { + None + }; + let content = serde_json::to_string(&SpawnAgentsOnCsvResult { + job_id, + status: job.status.as_str().to_string(), + output_csv_path: job.output_csv_path, + total_items: progress.total_items, + completed_items: progress.completed_items, + failed_items: progress.failed_items, + job_error, + failed_item_errors, + }) + .map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize spawn_agents_on_csv result: {err}" + )) + })?; + Ok(FunctionToolOutput::from_text(content, Some(true))) +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index e2e020a96a..9766bfb573 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -47,6 +47,7 @@ use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_sandboxing::policy_transforms::normalize_additional_permissions; use codex_tools::ApplyPatchToolArgs; +use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500); @@ -292,6 +293,10 @@ async fn effective_patch_permissions( impl ToolHandler for ApplyPatchHandler { type Output = ApplyPatchToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("apply_patch") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index eab1f0f808..549edd5148 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -19,11 +19,23 @@ use std::time::Instant; use tokio::sync::oneshot; use tracing::warn; -pub struct DynamicToolHandler; +pub struct DynamicToolHandler { + tool_name: ToolName, +} + +impl DynamicToolHandler { + pub fn new(tool_name: ToolName) -> Self { + Self { tool_name } + } +} impl ToolHandler for DynamicToolHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -37,7 +49,6 @@ impl ToolHandler for DynamicToolHandler { session, turn, call_id, - tool_name, payload, .. } = invocation; @@ -52,13 +63,19 @@ impl ToolHandler for DynamicToolHandler { }; let args: Value = parse_arguments(&arguments)?; - let response = request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name, args) - .await - .ok_or_else(|| { - FunctionCallError::RespondToModel( - "dynamic tool call was cancelled before receiving a response".to_string(), - ) - })?; + let response = request_dynamic_tool( + &session, + turn.as_ref(), + call_id, + self.tool_name.clone(), + args, + ) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "dynamic tool call was cancelled before receiving a response".to_string(), + ) + })?; let DynamicToolResponse { content_items, diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs index 74391d57bf..28e33f2be4 100644 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -5,28 +5,20 @@ //! the existing goal complete. use crate::function_tool::FunctionCallError; -use crate::goals::CreateGoalRequest; -use crate::goals::GoalRuntimeEvent; -use crate::goals::SetGoalRequest; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; -use codex_tools::CREATE_GOAL_TOOL_NAME; -use codex_tools::GET_GOAL_TOOL_NAME; -use codex_tools::UPDATE_GOAL_TOOL_NAME; use serde::Deserialize; use serde::Serialize; use std::fmt::Write as _; -use std::sync::Arc; -pub struct GoalHandler; +mod create_goal; +mod get_goal; +mod update_goal; + +pub use create_goal::CreateGoalHandler; +pub use get_goal::GetGoalHandler; +pub use update_goal::UpdateGoalHandler; #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case")] @@ -76,113 +68,6 @@ impl GoalToolResponse { } } -impl ToolHandler for GoalHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - tool_name, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "goal handler received unsupported payload".to_string(), - )); - } - }; - - match tool_name.name.as_str() { - GET_GOAL_TOOL_NAME => handle_get_goal(session.as_ref()).await, - CREATE_GOAL_TOOL_NAME => { - handle_create_goal(session.as_ref(), turn.as_ref(), &arguments).await - } - UPDATE_GOAL_TOOL_NAME => handle_update_goal(&session, turn.as_ref(), &arguments).await, - other => Err(FunctionCallError::Fatal(format!( - "goal handler received unsupported tool: {other}" - ))), - } - } -} - -async fn handle_get_goal(session: &Session) -> Result { - let goal = session - .get_thread_goal() - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(goal, CompletionBudgetReport::Omit) -} - -async fn handle_create_goal( - session: &Session, - turn_context: &TurnContext, - arguments: &str, -) -> Result { - let args: CreateGoalArgs = parse_arguments(arguments)?; - let goal = session - .create_thread_goal( - turn_context, - CreateGoalRequest { - objective: args.objective, - token_budget: args.token_budget, - }, - ) - .await - .map_err(|err| { - if err - .chain() - .any(|cause| cause.to_string().contains("already has a goal")) - { - FunctionCallError::RespondToModel( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - .to_string(), - ) - } else { - FunctionCallError::RespondToModel(format_goal_error(err)) - } - })?; - goal_response(Some(goal), CompletionBudgetReport::Omit) -} - -async fn handle_update_goal( - session: &Arc, - turn_context: &TurnContext, - arguments: &str, -) -> Result { - let args: UpdateGoalArgs = parse_arguments(arguments)?; - if args.status != ThreadGoalStatus::Complete { - return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" - .to_string(), - )); - } - session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { turn_context }) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let goal = session - .set_thread_goal( - turn_context, - SetGoalRequest { - objective: None, - status: Some(ThreadGoalStatus::Complete), - token_budget: None, - }, - ) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(Some(goal), CompletionBudgetReport::Include) -} - fn format_goal_error(err: anyhow::Error) -> String { let mut message = err.to_string(); for cause in err.chain().skip(1) { diff --git a/codex-rs/core/src/tools/handlers/goal/create_goal.rs b/codex-rs/core/src/tools/handlers/goal/create_goal.rs new file mode 100644 index 0000000000..88297cc1af --- /dev/null +++ b/codex-rs/core/src/tools/handlers/goal/create_goal.rs @@ -0,0 +1,72 @@ +use crate::function_tool::FunctionCallError; +use crate::goals::CreateGoalRequest; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_tools::CREATE_GOAL_TOOL_NAME; +use codex_tools::ToolName; + +use super::CompletionBudgetReport; +use super::CreateGoalArgs; +use super::format_goal_error; +use super::goal_response; + +pub struct CreateGoalHandler; + +impl ToolHandler for CreateGoalHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain(CREATE_GOAL_TOOL_NAME) + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "goal handler received unsupported payload".to_string(), + )); + } + }; + + let args: CreateGoalArgs = parse_arguments(&arguments)?; + let goal = session + .create_thread_goal( + turn.as_ref(), + CreateGoalRequest { + objective: args.objective, + token_budget: args.token_budget, + }, + ) + .await + .map_err(|err| { + if err + .chain() + .any(|cause| cause.to_string().contains("already has a goal")) + { + FunctionCallError::RespondToModel( + "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + .to_string(), + ) + } else { + FunctionCallError::RespondToModel(format_goal_error(err)) + } + })?; + goal_response(Some(goal), CompletionBudgetReport::Omit) + } +} diff --git a/codex-rs/core/src/tools/handlers/goal/get_goal.rs b/codex-rs/core/src/tools/handlers/goal/get_goal.rs new file mode 100644 index 0000000000..ab023f3014 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/goal/get_goal.rs @@ -0,0 +1,45 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_tools::GET_GOAL_TOOL_NAME; +use codex_tools::ToolName; + +use super::CompletionBudgetReport; +use super::format_goal_error; +use super::goal_response; + +pub struct GetGoalHandler; + +impl ToolHandler for GetGoalHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain(GET_GOAL_TOOL_NAME) + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, payload, .. + } = invocation; + + match payload { + ToolPayload::Function { .. } => { + let goal = session + .get_thread_goal() + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + goal_response(goal, CompletionBudgetReport::Omit) + } + _ => Err(FunctionCallError::RespondToModel( + "get_goal handler received unsupported payload".to_string(), + )), + } + } +} diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs new file mode 100644 index 0000000000..6c43484ec9 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -0,0 +1,75 @@ +use crate::function_tool::FunctionCallError; +use crate::goals::GoalRuntimeEvent; +use crate::goals::SetGoalRequest; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::protocol::ThreadGoalStatus; +use codex_tools::ToolName; +use codex_tools::UPDATE_GOAL_TOOL_NAME; + +use super::CompletionBudgetReport; +use super::UpdateGoalArgs; +use super::format_goal_error; +use super::goal_response; + +pub struct UpdateGoalHandler; + +impl ToolHandler for UpdateGoalHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain(UPDATE_GOAL_TOOL_NAME) + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "update_goal handler received unsupported payload".to_string(), + )); + } + }; + + let args: UpdateGoalArgs = parse_arguments(&arguments)?; + if args.status != ThreadGoalStatus::Complete { + return Err(FunctionCallError::RespondToModel( + "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + .to_string(), + )); + } + session + .goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { + turn_context: turn.as_ref(), + }) + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + let goal = session + .set_thread_goal( + turn.as_ref(), + SetGoalRequest { + objective: None, + status: Some(ThreadGoalStatus::Complete), + token_budget: None, + }, + ) + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + goal_response(Some(goal), CompletionBudgetReport::Include) + } +} diff --git a/codex-rs/core/src/tools/handlers/list_dir.rs b/codex-rs/core/src/tools/handlers/list_dir.rs deleted file mode 100644 index 0479060038..0000000000 --- a/codex-rs/core/src/tools/handlers/list_dir.rs +++ /dev/null @@ -1,294 +0,0 @@ -use std::collections::VecDeque; -use std::ffi::OsStr; -use std::fs::FileType; -use std::path::Path; -use std::path::PathBuf; - -use codex_protocol::permissions::ReadDenyMatcher; -use codex_utils_string::take_bytes_at_char_boundary; -use serde::Deserialize; -use tokio::fs; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct ListDirHandler; - -const DENY_READ_POLICY_MESSAGE: &str = - "access denied: reading this path is blocked by filesystem deny_read policy"; -const MAX_ENTRY_LENGTH: usize = 500; -const INDENTATION_SPACES: usize = 2; - -fn default_offset() -> usize { - 1 -} - -fn default_limit() -> usize { - 25 -} - -fn default_depth() -> usize { - 2 -} - -#[derive(Deserialize)] -struct ListDirArgs { - dir_path: String, - #[serde(default = "default_offset")] - offset: usize, - #[serde(default = "default_limit")] - limit: usize, - #[serde(default = "default_depth")] - depth: usize, -} - -impl ToolHandler for ListDirHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, turn, .. } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "list_dir handler received unsupported payload".to_string(), - )); - } - }; - - let args: ListDirArgs = parse_arguments(&arguments)?; - - let ListDirArgs { - dir_path, - offset, - limit, - depth, - } = args; - - if offset == 0 { - return Err(FunctionCallError::RespondToModel( - "offset must be a 1-indexed entry number".to_string(), - )); - } - - if limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - if depth == 0 { - return Err(FunctionCallError::RespondToModel( - "depth must be greater than zero".to_string(), - )); - } - - let path = PathBuf::from(&dir_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "dir_path must be an absolute path".to_string(), - )); - } - let file_system_sandbox_policy = turn.file_system_sandbox_policy(); - let read_deny_matcher = ReadDenyMatcher::new(&file_system_sandbox_policy, &turn.cwd); - if read_deny_matcher - .as_ref() - .is_some_and(|matcher| matcher.is_read_denied(&path)) - { - return Err(FunctionCallError::RespondToModel(format!( - "{DENY_READ_POLICY_MESSAGE}: `{}`", - path.display() - ))); - } - - let entries = - list_dir_slice_with_policy(&path, offset, limit, depth, read_deny_matcher.as_ref()) - .await?; - let mut output = Vec::with_capacity(entries.len() + 1); - output.push(format!("Absolute path: {}", path.display())); - output.extend(entries); - Ok(FunctionToolOutput::from_text(output.join("\n"), Some(true))) - } -} - -async fn list_dir_slice_with_policy( - path: &Path, - offset: usize, - limit: usize, - depth: usize, - read_deny_matcher: Option<&ReadDenyMatcher>, -) -> Result, FunctionCallError> { - let mut entries = Vec::new(); - collect_entries(path, Path::new(""), depth, read_deny_matcher, &mut entries).await?; - - if entries.is_empty() { - return Ok(Vec::new()); - } - - entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - - let start_index = offset - 1; - if start_index >= entries.len() { - return Err(FunctionCallError::RespondToModel( - "offset exceeds directory entry count".to_string(), - )); - } - - let remaining_entries = entries.len() - start_index; - let capped_limit = limit.min(remaining_entries); - let end_index = start_index + capped_limit; - let selected_entries = &entries[start_index..end_index]; - let mut formatted = Vec::with_capacity(selected_entries.len()); - - for entry in selected_entries { - formatted.push(format_entry_line(entry)); - } - - if end_index < entries.len() { - formatted.push(format!("More than {capped_limit} entries found")); - } - - Ok(formatted) -} - -async fn collect_entries( - dir_path: &Path, - relative_prefix: &Path, - depth: usize, - read_deny_matcher: Option<&ReadDenyMatcher>, - entries: &mut Vec, -) -> Result<(), FunctionCallError> { - let mut queue = VecDeque::new(); - queue.push_back((dir_path.to_path_buf(), relative_prefix.to_path_buf(), depth)); - - while let Some((current_dir, prefix, remaining_depth)) = queue.pop_front() { - let mut read_dir = fs::read_dir(¤t_dir).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read directory: {err}")) - })?; - - let mut dir_entries = Vec::new(); - - while let Some(entry) = read_dir.next_entry().await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read directory: {err}")) - })? { - let entry_path = entry.path(); - if let Some(read_deny_matcher) = read_deny_matcher - && read_deny_matcher.is_read_denied(&entry_path) - { - continue; - } - - let file_type = entry.file_type().await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to inspect entry: {err}")) - })?; - - let file_name = entry.file_name(); - let relative_path = if prefix.as_os_str().is_empty() { - PathBuf::from(&file_name) - } else { - prefix.join(&file_name) - }; - - let display_name = format_entry_component(&file_name); - let display_depth = prefix.components().count(); - let sort_key = format_entry_name(&relative_path); - let kind = DirEntryKind::from(&file_type); - dir_entries.push(( - entry_path, - relative_path, - kind, - DirEntry { - name: sort_key, - display_name, - depth: display_depth, - kind, - }, - )); - } - - dir_entries.sort_unstable_by(|a, b| a.3.name.cmp(&b.3.name)); - - for (entry_path, relative_path, kind, dir_entry) in dir_entries { - if kind == DirEntryKind::Directory && remaining_depth > 1 { - queue.push_back((entry_path, relative_path, remaining_depth - 1)); - } - entries.push(dir_entry); - } - } - - Ok(()) -} - -fn format_entry_name(path: &Path) -> String { - let normalized = path.to_string_lossy().replace("\\", "/"); - if normalized.len() > MAX_ENTRY_LENGTH { - take_bytes_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string() - } else { - normalized - } -} - -fn format_entry_component(name: &OsStr) -> String { - let normalized = name.to_string_lossy(); - if normalized.len() > MAX_ENTRY_LENGTH { - take_bytes_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string() - } else { - normalized.to_string() - } -} - -fn format_entry_line(entry: &DirEntry) -> String { - let indent = " ".repeat(entry.depth * INDENTATION_SPACES); - let mut name = entry.display_name.clone(); - match entry.kind { - DirEntryKind::Directory => name.push('/'), - DirEntryKind::Symlink => name.push('@'), - DirEntryKind::Other => name.push('?'), - DirEntryKind::File => {} - } - format!("{indent}{name}") -} - -#[derive(Clone)] -struct DirEntry { - name: String, - display_name: String, - depth: usize, - kind: DirEntryKind, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum DirEntryKind { - Directory, - File, - Symlink, - Other, -} - -impl From<&FileType> for DirEntryKind { - fn from(file_type: &FileType) -> Self { - if file_type.is_symlink() { - DirEntryKind::Symlink - } else if file_type.is_dir() { - DirEntryKind::Directory - } else if file_type.is_file() { - DirEntryKind::File - } else { - DirEntryKind::Other - } - } -} - -#[cfg(test)] -#[path = "list_dir_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/list_dir_tests.rs b/codex-rs/core/src/tools/handlers/list_dir_tests.rs deleted file mode 100644 index 7de107a18d..0000000000 --- a/codex-rs/core/src/tools/handlers/list_dir_tests.rs +++ /dev/null @@ -1,331 +0,0 @@ -use super::*; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::ReadDenyMatcher; -use pretty_assertions::assert_eq; -use tempfile::tempdir; - -async fn list_dir_slice( - path: &Path, - offset: usize, - limit: usize, - depth: usize, -) -> Result, FunctionCallError> { - list_dir_slice_with_policy(path, offset, limit, depth, /*read_deny_matcher*/ None).await -} - -#[tokio::test] -async fn lists_directory_entries() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - - let sub_dir = dir_path.join("nested"); - tokio::fs::create_dir(&sub_dir) - .await - .expect("create sub dir"); - - let deeper_dir = sub_dir.join("deeper"); - tokio::fs::create_dir(&deeper_dir) - .await - .expect("create deeper dir"); - - tokio::fs::write(dir_path.join("entry.txt"), b"content") - .await - .expect("write file"); - tokio::fs::write(sub_dir.join("child.txt"), b"child") - .await - .expect("write child"); - tokio::fs::write(deeper_dir.join("grandchild.txt"), b"grandchild") - .await - .expect("write grandchild"); - - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let link_path = dir_path.join("link"); - symlink(dir_path.join("entry.txt"), &link_path).expect("create symlink"); - } - - let entries = list_dir_slice( - dir_path, /*offset*/ 1, /*limit*/ 20, /*depth*/ 3, - ) - .await - .expect("list directory"); - - #[cfg(unix)] - let expected = vec![ - "entry.txt".to_string(), - "link@".to_string(), - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - " grandchild.txt".to_string(), - ]; - - #[cfg(not(unix))] - let expected = vec![ - "entry.txt".to_string(), - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - " grandchild.txt".to_string(), - ]; - - assert_eq!(entries, expected); -} - -#[tokio::test] -async fn errors_when_offset_exceeds_entries() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - tokio::fs::create_dir(dir_path.join("nested")) - .await - .expect("create sub dir"); - - let err = list_dir_slice( - dir_path, /*offset*/ 10, /*limit*/ 1, /*depth*/ 2, - ) - .await - .expect_err("offset exceeds entries"); - assert_eq!( - err, - FunctionCallError::RespondToModel("offset exceeds directory entry count".to_string()) - ); -} - -#[tokio::test] -async fn respects_depth_parameter() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - let nested = dir_path.join("nested"); - let deeper = nested.join("deeper"); - tokio::fs::create_dir(&nested).await.expect("create nested"); - tokio::fs::create_dir(&deeper).await.expect("create deeper"); - tokio::fs::write(dir_path.join("root.txt"), b"root") - .await - .expect("write root"); - tokio::fs::write(nested.join("child.txt"), b"child") - .await - .expect("write nested"); - tokio::fs::write(deeper.join("grandchild.txt"), b"deep") - .await - .expect("write deeper"); - - let entries_depth_one = list_dir_slice( - dir_path, /*offset*/ 1, /*limit*/ 10, /*depth*/ 1, - ) - .await - .expect("list depth 1"); - assert_eq!( - entries_depth_one, - vec!["nested/".to_string(), "root.txt".to_string(),] - ); - - let entries_depth_two = list_dir_slice( - dir_path, /*offset*/ 1, /*limit*/ 20, /*depth*/ 2, - ) - .await - .expect("list depth 2"); - assert_eq!( - entries_depth_two, - vec![ - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - "root.txt".to_string(), - ] - ); - - let entries_depth_three = list_dir_slice( - dir_path, /*offset*/ 1, /*limit*/ 30, /*depth*/ 3, - ) - .await - .expect("list depth 3"); - assert_eq!( - entries_depth_three, - vec![ - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - " grandchild.txt".to_string(), - "root.txt".to_string(), - ] - ); -} - -#[tokio::test] -async fn paginates_in_sorted_order() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - - let dir_a = dir_path.join("a"); - let dir_b = dir_path.join("b"); - tokio::fs::create_dir(&dir_a).await.expect("create a"); - tokio::fs::create_dir(&dir_b).await.expect("create b"); - - tokio::fs::write(dir_a.join("a_child.txt"), b"a") - .await - .expect("write a child"); - tokio::fs::write(dir_b.join("b_child.txt"), b"b") - .await - .expect("write b child"); - - let first_page = list_dir_slice( - dir_path, /*offset*/ 1, /*limit*/ 2, /*depth*/ 2, - ) - .await - .expect("list page one"); - assert_eq!( - first_page, - vec![ - "a/".to_string(), - " a_child.txt".to_string(), - "More than 2 entries found".to_string() - ] - ); - - let second_page = list_dir_slice( - dir_path, /*offset*/ 3, /*limit*/ 2, /*depth*/ 2, - ) - .await - .expect("list page two"); - assert_eq!( - second_page, - vec!["b/".to_string(), " b_child.txt".to_string()] - ); -} - -#[tokio::test] -async fn handles_large_limit_without_overflow() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - tokio::fs::write(dir_path.join("alpha.txt"), b"alpha") - .await - .expect("write alpha"); - tokio::fs::write(dir_path.join("beta.txt"), b"beta") - .await - .expect("write beta"); - tokio::fs::write(dir_path.join("gamma.txt"), b"gamma") - .await - .expect("write gamma"); - - let entries = list_dir_slice(dir_path, /*offset*/ 2, usize::MAX, /*depth*/ 1) - .await - .expect("list without overflow"); - assert_eq!( - entries, - vec!["beta.txt".to_string(), "gamma.txt".to_string(),] - ); -} - -#[tokio::test] -async fn indicates_truncated_results() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - - for idx in 0..40 { - let file = dir_path.join(format!("file_{idx:02}.txt")); - tokio::fs::write(file, b"content") - .await - .expect("write file"); - } - - let entries = list_dir_slice( - dir_path, /*offset*/ 1, /*limit*/ 25, /*depth*/ 1, - ) - .await - .expect("list directory"); - assert_eq!(entries.len(), 26); - assert_eq!( - entries.last(), - Some(&"More than 25 entries found".to_string()) - ); -} - -#[tokio::test] -async fn truncation_respects_sorted_order() -> anyhow::Result<()> { - let temp = tempdir()?; - let dir_path = temp.path(); - let nested = dir_path.join("nested"); - let deeper = nested.join("deeper"); - tokio::fs::create_dir(&nested).await?; - tokio::fs::create_dir(&deeper).await?; - tokio::fs::write(dir_path.join("root.txt"), b"root").await?; - tokio::fs::write(nested.join("child.txt"), b"child").await?; - tokio::fs::write(deeper.join("grandchild.txt"), b"deep").await?; - - let entries_depth_three = list_dir_slice( - dir_path, /*offset*/ 1, /*limit*/ 3, /*depth*/ 3, - ) - .await?; - assert_eq!( - entries_depth_three, - vec![ - "nested/".to_string(), - " child.txt".to_string(), - " deeper/".to_string(), - "More than 3 entries found".to_string() - ] - ); - - Ok(()) -} - -#[tokio::test] -async fn hides_denied_entries_and_prunes_denied_subtrees() { - let temp = tempdir().expect("create tempdir"); - let dir_path = temp.path(); - let visible_dir = dir_path.join("visible"); - let denied_dir = dir_path.join("private"); - tokio::fs::create_dir(&visible_dir) - .await - .expect("create visible dir"); - tokio::fs::create_dir(&denied_dir) - .await - .expect("create denied dir"); - tokio::fs::write(visible_dir.join("ok.txt"), b"ok") - .await - .expect("write visible file"); - tokio::fs::write(denied_dir.join("secret.txt"), b"secret") - .await - .expect("write denied file"); - tokio::fs::write(dir_path.join("top_secret.txt"), b"secret") - .await - .expect("write denied top-level file"); - - let policy = FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: denied_dir.try_into().expect("absolute denied dir"), - }, - access: FileSystemAccessMode::None, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: dir_path - .join("top_secret.txt") - .try_into() - .expect("absolute denied file"), - }, - access: FileSystemAccessMode::None, - }, - ]); - - let read_deny_matcher = ReadDenyMatcher::new(&policy, dir_path); - let entries = list_dir_slice_with_policy( - dir_path, - /*offset*/ 1, - /*limit*/ 20, - /*depth*/ 3, - read_deny_matcher.as_ref(), - ) - .await - .expect("list directory"); - - assert_eq!( - entries, - vec!["visible/".to_string(), " ok.txt".to_string(),] - ); -} diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 568e456158..4dfcb44b1f 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -13,12 +13,26 @@ use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; use serde_json::Value; -pub struct McpHandler; +pub struct McpHandler { + tool_name: ToolName, +} + +impl McpHandler { + pub fn new(tool_name: ToolName) -> Self { + Self { tool_name } + } +} + impl ToolHandler for McpHandler { type Output = McpToolOutput; + fn tool_name(&self) -> ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Mcp } @@ -29,7 +43,7 @@ impl ToolHandler for McpHandler { }; Some(PreToolUsePayload { - tool_name: HookToolName::new(invocation.tool_name.display()), + tool_name: HookToolName::new(self.tool_name.display()), tool_input: mcp_hook_tool_input(raw_arguments), }) } @@ -46,7 +60,7 @@ impl ToolHandler for McpHandler { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { - tool_name: HookToolName::new(invocation.tool_name.display()), + tool_name: HookToolName::new(self.tool_name.display()), tool_use_id: invocation.call_id.clone(), tool_input: result.tool_input.clone(), tool_response, @@ -58,7 +72,6 @@ impl ToolHandler for McpHandler { session, turn, call_id, - tool_name: model_tool_name, payload, .. } = invocation; @@ -86,7 +99,7 @@ impl ToolHandler for McpHandler { call_id.clone(), server, tool, - model_tool_name.display(), + self.tool_name.display(), arguments_str, ) .await; @@ -134,9 +147,13 @@ mod tests { .to_string(), }; let (session, turn) = make_session_and_context().await; + let handler = McpHandler::new(codex_tools::ToolName::namespaced( + "mcp__memory__", + "create_entities", + )); assert_eq!( - McpHandler.pre_tool_use_payload(&ToolInvocation { + handler.pre_tool_use_payload(&ToolInvocation { session: session.into(), turn: turn.into(), cancellation_token: tokio_util::sync::CancellationToken::new(), @@ -185,6 +202,10 @@ mod tests { truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024), }; let (session, turn) = make_session_and_context().await; + let handler = McpHandler::new(codex_tools::ToolName::namespaced( + "mcp__filesystem__", + "read_file", + )); let invocation = ToolInvocation { session: session.into(), turn: turn.into(), @@ -196,7 +217,7 @@ mod tests { payload, }; assert_eq!( - McpHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(PostToolUsePayload { tool_name: HookToolName::new("mcp__filesystem__read_file"), tool_use_id: "call-mcp-post".to_string(), diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index 14f8db3a4c..630a1cccb4 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -1,18 +1,14 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use std::time::Instant; use codex_protocol::items::McpToolCallError; use codex_protocol::items::McpToolCallItem; use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::TurnItem; use codex_protocol::mcp::CallToolResult; -use codex_protocol::models::function_call_output_content_items_to_text; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; -use rmcp::model::PaginatedRequestParams; -use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::Resource; use rmcp::model::ResourceTemplate; @@ -25,13 +21,15 @@ use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; use codex_protocol::protocol::McpInvocation; -pub struct McpResourceHandler; +mod list_mcp_resource_templates; +mod list_mcp_resources; +mod read_mcp_resource; + +pub use list_mcp_resource_templates::ListMcpResourceTemplatesHandler; +pub use list_mcp_resources::ListMcpResourcesHandler; +pub use read_mcp_resource::ReadMcpResourceHandler; #[derive(Debug, Deserialize, Default)] struct ListResourcesArgs { @@ -178,378 +176,6 @@ struct ReadResourcePayload { result: ReadResourceResult, } -impl ToolHandler for McpResourceHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - call_id, - tool_name, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "mcp_resource handler received unsupported payload".to_string(), - )); - } - }; - - let arguments_value = parse_arguments(arguments.as_str())?; - - match tool_name.name.as_str() { - "list_mcp_resources" => { - handle_list_resources( - Arc::clone(&session), - Arc::clone(&turn), - call_id.clone(), - arguments_value.clone(), - ) - .await - } - "list_mcp_resource_templates" => { - handle_list_resource_templates( - Arc::clone(&session), - Arc::clone(&turn), - call_id.clone(), - arguments_value.clone(), - ) - .await - } - "read_mcp_resource" => { - handle_read_resource( - Arc::clone(&session), - Arc::clone(&turn), - call_id, - arguments_value, - ) - .await - } - other => Err(FunctionCallError::RespondToModel(format!( - "unsupported MCP resource tool: {other}" - ))), - } - } -} - -#[expect( - clippy::await_holding_invalid_type, - reason = "MCP resource listing reads through the session-owned manager guard" -)] -async fn handle_list_resources( - session: Arc, - turn: Arc, - call_id: String, - arguments: Option, -) -> Result { - let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?; - let ListResourcesArgs { server, cursor } = args; - let server = normalize_optional_string(server); - let cursor = normalize_optional_string(cursor); - - let invocation = McpInvocation { - server: server.clone().unwrap_or_else(|| "codex".to_string()), - tool: "list_mcp_resources".to_string(), - arguments: arguments.clone(), - }; - - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; - let start = Instant::now(); - - let payload_result: Result = async { - if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); - let result = session - .list_resources(&server_name, params) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}")) - })?; - Ok(ListResourcesPayload::from_single_server( - server_name, - result, - )) - } else { - if cursor.is_some() { - return Err(FunctionCallError::RespondToModel( - "cursor can only be used when a server is specified".to_string(), - )); - } - - let resources = session - .services - .mcp_connection_manager - .read() - .await - .list_all_resources() - .await; - Ok(ListResourcesPayload::from_all_servers(resources)) - } - } - .await; - - match payload_result { - Ok(payload) => match serialize_function_output(payload) { - Ok(output) => { - let content = - function_call_output_content_items_to_text(&output.body).unwrap_or_default(); - let duration = start.elapsed(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Ok(call_tool_result_from_content(&content, output.success)), - ) - .await; - Ok(output) - } - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - }, - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - } -} - -#[expect( - clippy::await_holding_invalid_type, - reason = "MCP resource template listing reads through the session-owned manager guard" -)] -async fn handle_list_resource_templates( - session: Arc, - turn: Arc, - call_id: String, - arguments: Option, -) -> Result { - let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?; - let ListResourceTemplatesArgs { server, cursor } = args; - let server = normalize_optional_string(server); - let cursor = normalize_optional_string(cursor); - - let invocation = McpInvocation { - server: server.clone().unwrap_or_else(|| "codex".to_string()), - tool: "list_mcp_resource_templates".to_string(), - arguments: arguments.clone(), - }; - - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; - let start = Instant::now(); - - let payload_result: Result = async { - if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); - let result = session - .list_resource_templates(&server_name, params) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "resources/templates/list failed: {err:#}" - )) - })?; - Ok(ListResourceTemplatesPayload::from_single_server( - server_name, - result, - )) - } else { - if cursor.is_some() { - return Err(FunctionCallError::RespondToModel( - "cursor can only be used when a server is specified".to_string(), - )); - } - - let templates = session - .services - .mcp_connection_manager - .read() - .await - .list_all_resource_templates() - .await; - Ok(ListResourceTemplatesPayload::from_all_servers(templates)) - } - } - .await; - - match payload_result { - Ok(payload) => match serialize_function_output(payload) { - Ok(output) => { - let content = - function_call_output_content_items_to_text(&output.body).unwrap_or_default(); - let duration = start.elapsed(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Ok(call_tool_result_from_content(&content, output.success)), - ) - .await; - Ok(output) - } - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - }, - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - } -} - -async fn handle_read_resource( - session: Arc, - turn: Arc, - call_id: String, - arguments: Option, -) -> Result { - let args: ReadResourceArgs = parse_args(arguments.clone())?; - let ReadResourceArgs { server, uri } = args; - let server = normalize_required_string("server", server)?; - let uri = normalize_required_string("uri", uri)?; - - let invocation = McpInvocation { - server: server.clone(), - tool: "read_mcp_resource".to_string(), - arguments: arguments.clone(), - }; - - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; - let start = Instant::now(); - - let payload_result: Result = async { - let result = session - .read_resource( - &server, - ReadResourceRequestParams { - meta: None, - uri: uri.clone(), - }, - ) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) - })?; - - Ok(ReadResourcePayload { - server, - uri, - result, - }) - } - .await; - - match payload_result { - Ok(payload) => match serialize_function_output(payload) { - Ok(output) => { - let content = - function_call_output_content_items_to_text(&output.body).unwrap_or_default(); - let duration = start.elapsed(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Ok(call_tool_result_from_content(&content, output.success)), - ) - .await; - Ok(output) - } - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - }, - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - } -} - fn call_tool_result_from_content(content: &str, success: Option) -> CallToolResult { CallToolResult { content: vec![serde_json::json!({"type": "text", "text": content})], diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs new file mode 100644 index 0000000000..2c87edf0c7 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -0,0 +1,160 @@ +use std::time::Instant; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::models::function_call_output_content_items_to_text; +use codex_protocol::protocol::McpInvocation; +use codex_tools::ToolName; + +use rmcp::model::PaginatedRequestParams; + +use super::ListResourceTemplatesArgs; +use super::ListResourceTemplatesPayload; +use super::call_tool_result_from_content; +use super::emit_tool_call_begin; +use super::emit_tool_call_end; +use super::normalize_optional_string; +use super::parse_args_with_default; +use super::parse_arguments; +use super::serialize_function_output; + +pub struct ListMcpResourceTemplatesHandler; + +impl ToolHandler for ListMcpResourceTemplatesHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("list_mcp_resource_templates") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "MCP resource template listing reads through the session-owned manager guard" + )] + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "list_mcp_resource_templates handler received unsupported payload".to_string(), + )); + } + }; + + let arguments = parse_arguments(arguments.as_str())?; + let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?; + let ListResourceTemplatesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resource_templates".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor.clone().map(|value| PaginatedRequestParams { + meta: None, + cursor: Some(value), + }); + let result = session + .list_resource_templates(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "resources/templates/list failed: {err:#}" + )) + })?; + Ok(ListResourceTemplatesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let templates = session + .services + .mcp_connection_manager + .read() + .await + .list_all_resource_templates() + .await; + Ok(ListResourceTemplatesPayload::from_all_servers(templates)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let content = function_call_output_content_items_to_text(&output.body) + .unwrap_or_default(); + let duration = start.elapsed(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(&content, output.success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } + } +} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs new file mode 100644 index 0000000000..ed62852141 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -0,0 +1,158 @@ +use std::time::Instant; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::models::function_call_output_content_items_to_text; +use codex_protocol::protocol::McpInvocation; +use codex_tools::ToolName; + +use rmcp::model::PaginatedRequestParams; + +use super::ListResourcesArgs; +use super::ListResourcesPayload; +use super::call_tool_result_from_content; +use super::emit_tool_call_begin; +use super::emit_tool_call_end; +use super::normalize_optional_string; +use super::parse_args_with_default; +use super::parse_arguments; +use super::serialize_function_output; + +pub struct ListMcpResourcesHandler; + +impl ToolHandler for ListMcpResourcesHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("list_mcp_resources") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "MCP resource listing reads through the session-owned manager guard" + )] + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "list_mcp_resources handler received unsupported payload".to_string(), + )); + } + }; + + let arguments = parse_arguments(arguments.as_str())?; + let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?; + let ListResourcesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resources".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor.clone().map(|value| PaginatedRequestParams { + meta: None, + cursor: Some(value), + }); + let result = session + .list_resources(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}")) + })?; + Ok(ListResourcesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let resources = session + .services + .mcp_connection_manager + .read() + .await + .list_all_resources() + .await; + Ok(ListResourcesPayload::from_all_servers(resources)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let content = function_call_output_content_items_to_text(&output.body) + .unwrap_or_default(); + let duration = start.elapsed(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(&content, output.success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } + } +} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs new file mode 100644 index 0000000000..91d5a4317e --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -0,0 +1,141 @@ +use std::time::Instant; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::models::function_call_output_content_items_to_text; +use codex_protocol::protocol::McpInvocation; +use codex_tools::ToolName; + +use rmcp::model::ReadResourceRequestParams; + +use super::ReadResourceArgs; +use super::ReadResourcePayload; +use super::call_tool_result_from_content; +use super::emit_tool_call_begin; +use super::emit_tool_call_end; +use super::normalize_required_string; +use super::parse_args; +use super::parse_arguments; +use super::serialize_function_output; + +pub struct ReadMcpResourceHandler; + +impl ToolHandler for ReadMcpResourceHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("read_mcp_resource") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "read_mcp_resource handler received unsupported payload".to_string(), + )); + } + }; + + let arguments = parse_arguments(arguments.as_str())?; + let args: ReadResourceArgs = parse_args(arguments.clone())?; + let ReadResourceArgs { server, uri } = args; + let server = normalize_required_string("server", server)?; + let uri = normalize_required_string("uri", uri)?; + + let invocation = McpInvocation { + server: server.clone(), + tool: "read_mcp_resource".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + let result = session + .read_resource( + &server, + ReadResourceRequestParams { + meta: None, + uri: uri.clone(), + }, + ) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) + })?; + + Ok(ReadResourcePayload { + server, + uri, + result, + }) + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let content = function_call_output_content_items_to_text(&output.body) + .unwrap_or_default(); + let duration = start.elapsed(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(&content, output.success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index f7ad4f7e67..7f9119583b 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,7 +2,6 @@ pub(crate) mod agent_jobs; pub(crate) mod apply_patch; mod dynamic; mod goal; -mod list_dir; mod mcp; mod mcp_resource; pub(crate) mod multi_agents; @@ -31,27 +30,35 @@ use std::path::Path; use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler; pub(crate) use crate::tools::code_mode::CodeModeWaitHandler; pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use goal::GoalHandler; -pub use list_dir::ListDirHandler; +pub use goal::CreateGoalHandler; +pub use goal::GetGoalHandler; +pub use goal::UpdateGoalHandler; pub use mcp::McpHandler; -pub use mcp_resource::McpResourceHandler; +pub use mcp_resource::ListMcpResourceTemplatesHandler; +pub use mcp_resource::ListMcpResourcesHandler; +pub use mcp_resource::ReadMcpResourceHandler; pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; +pub use shell::ContainerExecHandler; +pub use shell::LocalShellHandler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; pub use tool_search::ToolSearchHandler; pub use unavailable_tool::UnavailableToolHandler; pub(crate) use unavailable_tool::unavailable_tool_message; -pub use unified_exec::UnifiedExecHandler; +pub use unified_exec::ExecCommandHandler; +pub use unified_exec::WriteStdinHandler; pub use view_image::ViewImageHandler; fn parse_arguments(arguments: &str) -> Result @@ -86,6 +93,27 @@ fn resolve_workdir_base_path( .map_or_else(|| default_cwd.clone(), |workdir| default_cwd.join(workdir))) } +fn resolve_tool_environment<'a>( + turn: &'a TurnContext, + environment_id: Option<&str>, +) -> Result, FunctionCallError> { + environment_id.map_or_else( + || Ok(turn.environments.primary()), + |environment_id| { + turn.environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id) + .map(Some) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "unknown turn environment id `{environment_id}`" + )) + }) + }, + ) +} + /// Validates feature/policy constraints for `with_additional_permissions` and /// normalizes any path-based permissions. Errors if the request is invalid. pub(crate) fn normalize_and_validate_additional_permissions( diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 2d70d3e92d..71ef84fd19 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -32,6 +32,7 @@ use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; +use codex_tools::ToolName; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index 0b308bb09e..70d24c4288 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -6,6 +6,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = CloseAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("close_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 59a5038934..8fa4622617 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -8,6 +8,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = ResumeAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("resume_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 1feb21b839..0994ba5e2f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -7,6 +7,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = SendInputResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("send_input") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index bc5dcd6929..adfb926fe7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -13,6 +13,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = SpawnAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("spawn_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 49b85dbfb3..8d6c09193e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -18,6 +18,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = WaitAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("wait_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 1f246dc018..4d00b9afb1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -6,6 +6,7 @@ use crate::function_tool::FunctionCallError; use crate::init_state_db; use crate::session::tests::make_session_and_context; use crate::session_prefix::format_subagent_notification_message; +use crate::thread_manager::agent_graph_store_from_state_db; use crate::thread_manager::thread_store_from_config; use crate::tools::context::ToolOutput; use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; @@ -90,11 +91,12 @@ fn parse_agent_id(id: &str) -> ThreadId { ThreadId::from_string(id).expect("agent id should be valid") } -fn thread_manager() -> ThreadManager { +async fn thread_manager() -> ThreadManager { ThreadManager::with_models_provider_for_tests( CodexAuth::from_api_key("dummy"), built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["openai"].clone(), ) + .await } async fn install_role_with_model_override(turn: &mut TurnContext) -> String { @@ -241,7 +243,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { } let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let mut config = (*turn.config).clone(); let provider_info = @@ -296,7 +298,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { async fn spawn_agent_fork_context_rejects_agent_type_override() { let (mut session, mut turn) = make_session_and_context().await; let role_name = install_role_with_model_override(&mut turn).await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -328,7 +330,7 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() { #[tokio::test] async fn spawn_agent_fork_context_rejects_child_model_overrides() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -363,7 +365,7 @@ async fn spawn_agent_fork_context_rejects_child_model_overrides() { async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { let (mut session, mut turn) = make_session_and_context().await; let role_name = install_role_with_model_override(&mut turn).await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -406,7 +408,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { #[tokio::test] async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_overrides() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -447,7 +449,7 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { let (mut session, mut turn) = make_session_and_context().await; let role_name = install_role_with_model_override(&mut turn).await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -503,7 +505,7 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { #[tokio::test] async fn spawn_agent_returns_agent_id_without_task_name() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let output = SpawnAgentHandler @@ -530,7 +532,7 @@ async fn spawn_agent_returns_agent_id_without_task_name() { #[tokio::test] async fn multi_agent_v2_spawn_requires_task_name() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -564,7 +566,7 @@ async fn multi_agent_v2_spawn_requires_task_name() { #[tokio::test] async fn multi_agent_v2_spawn_rejects_legacy_items_field() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -624,7 +626,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat } let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -721,7 +723,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat #[tokio::test] async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -760,7 +762,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { #[tokio::test] async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -799,7 +801,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { #[tokio::test] async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -838,7 +840,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { #[tokio::test] async fn multi_agent_v2_send_message_accepts_root_target_from_child() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -914,7 +916,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { #[tokio::test] async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -995,7 +997,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { #[tokio::test] async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1089,7 +1091,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa #[tokio::test] async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1176,7 +1178,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { #[tokio::test] async fn multi_agent_v2_list_agents_omits_closed_agents() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1240,7 +1242,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { #[tokio::test] async fn multi_agent_v2_send_message_rejects_legacy_items_field() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1296,7 +1298,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() { #[tokio::test] async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1369,7 +1371,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { #[tokio::test] async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1504,7 +1506,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() #[tokio::test] async fn multi_agent_v2_followup_task_rejects_legacy_items_field() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1557,7 +1559,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() { #[tokio::test] async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1634,7 +1636,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { #[tokio::test] async fn multi_agent_v2_spawn_omits_agent_id_when_named() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1673,7 +1675,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() { #[tokio::test] async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -1716,7 +1718,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { } let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let expected_sandbox = turn.config.legacy_sandbox_policy(); let mut expected_file_system_sandbox_policy = @@ -1797,7 +1799,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { #[tokio::test] async fn spawn_agent_rejects_when_depth_limit_exceeded() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let max_depth = turn.config.agent_max_depth; @@ -1835,7 +1837,7 @@ async fn spawn_agent_allows_depth_up_to_configured_max_depth() { } let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let mut config = (*turn.config).clone(); @@ -1881,7 +1883,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() { } let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let mut config = (*turn.config).clone(); config.agent_max_depth = 1; config @@ -1989,7 +1991,7 @@ async fn send_input_rejects_invalid_id() { #[tokio::test] async fn send_input_reports_missing_agent() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let agent_id = ThreadId::new(); let invocation = invocation( @@ -2010,7 +2012,7 @@ async fn send_input_reports_missing_agent() { #[tokio::test] async fn send_input_interrupts_before_prompt() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -2052,7 +2054,7 @@ async fn send_input_interrupts_before_prompt() { #[tokio::test] async fn send_input_accepts_structured_items() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -2127,7 +2129,7 @@ async fn resume_agent_rejects_invalid_id() { #[tokio::test] async fn resume_agent_reports_missing_agent() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let agent_id = ThreadId::new(); let invocation = invocation( @@ -2148,7 +2150,7 @@ async fn resume_agent_reports_missing_agent() { #[tokio::test] async fn resume_agent_noops_for_active_agent() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -2187,7 +2189,7 @@ async fn resume_agent_noops_for_active_agent() { #[tokio::test] async fn resume_agent_restores_closed_agent_and_accepts_send_input() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -2266,7 +2268,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { #[tokio::test] async fn resume_agent_rejects_when_depth_limit_exceeded() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let max_depth = turn.config.agent_max_depth; @@ -2355,7 +2357,7 @@ async fn wait_agent_rejects_empty_targets() { #[tokio::test] async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -2494,7 +2496,7 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { #[tokio::test] async fn wait_agent_returns_not_found_for_missing_agents() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let id_a = ThreadId::new(); let id_b = ThreadId::new(); @@ -2530,7 +2532,7 @@ async fn wait_agent_returns_not_found_for_missing_agents() { #[tokio::test] async fn wait_agent_times_out_when_status_is_not_final() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -2573,7 +2575,7 @@ async fn wait_agent_times_out_when_status_is_not_final() { #[tokio::test] async fn wait_agent_clamps_short_timeouts_to_minimum() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -2611,7 +2613,7 @@ async fn wait_agent_clamps_short_timeouts_to_minimum() { #[tokio::test] async fn wait_agent_returns_final_status_without_timeout() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -2663,7 +2665,7 @@ async fn wait_agent_returns_final_status_without_timeout() { #[tokio::test] async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -2754,7 +2756,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { #[tokio::test] async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -2832,7 +2834,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { #[tokio::test] async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -2920,7 +2922,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { #[tokio::test] async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -3006,7 +3008,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { #[tokio::test] async fn multi_agent_v2_close_agent_accepts_task_name_target() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -3065,7 +3067,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { #[tokio::test] async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { let (mut session, mut turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; let root = manager .start_thread((*turn.config).clone()) .await @@ -3113,7 +3115,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; - let manager = thread_manager(); + let manager = thread_manager().await; session.services.agent_control = manager.agent_control(); let config = turn.config.as_ref().clone(); let thread = manager @@ -3158,15 +3160,19 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr .features .enable(Feature::Sqlite) .expect("test config should allow sqlite"); - let state_db = init_state_db(&config).await; + let state_db = init_state_db(&config) + .await + .expect("test config should initialize state db"); let manager = ThreadManager::new( &config, AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), state_db.clone(), + thread_store_from_config(&config, state_db.clone()), + agent_graph_store_from_state_db(state_db.clone()), + "11111111-1111-4111-8111-111111111111".to_string(), ); let parent = manager diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index b561c5acb4..a477c25ca4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -22,6 +22,7 @@ use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; +use codex_tools::ToolName; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index c0a1bcbc53..f09bc7f343 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -6,6 +6,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = CloseAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("close_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs index bcb3f49dea..a5dfcb09dd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs @@ -9,6 +9,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("followup_task") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index 579c441993..dabfe72a7d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -6,6 +6,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = ListAgentsResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("list_agents") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 12e443b814..dcf1a1e583 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -62,15 +62,7 @@ pub(crate) async fn handle_message_string_tool( target: String, message: String, ) -> Result { - handle_message_submission(invocation, mode, target, message_content(message)?).await -} - -async fn handle_message_submission( - invocation: ToolInvocation, - mode: MessageDeliveryMode, - target: String, - prompt: String, -) -> Result { + let prompt = message_content(message)?; let ToolInvocation { session, turn, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs index b327ccf520..e814c69f5f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -9,6 +9,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("send_message") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 184ea36c51..8f09dbcaf2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -15,6 +15,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = SpawnAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("spawn_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index b86e237f53..706c0ad6ac 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -10,6 +10,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = WaitAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("wait_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 71636229eb..ce217f457d 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -1,6 +1,4 @@ use crate::function_tool::FunctionCallError; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -11,6 +9,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::EventMsg; +use codex_tools::ToolName; use serde_json::Value as JsonValue; pub struct PlanHandler; @@ -46,6 +45,10 @@ impl ToolOutput for PlanToolOutput { impl ToolHandler for PlanHandler { type Output = PlanToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("update_plan") + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -54,7 +57,7 @@ impl ToolHandler for PlanHandler { let ToolInvocation { session, turn, - call_id, + call_id: _, payload, .. } = invocation; @@ -68,33 +71,21 @@ impl ToolHandler for PlanHandler { } }; - handle_update_plan(session.as_ref(), turn.as_ref(), arguments, call_id).await?; + if turn.collaboration_mode.mode == ModeKind::Plan { + return Err(FunctionCallError::RespondToModel( + "update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(), + )); + } + + let args = parse_update_plan_arguments(&arguments)?; + session + .send_event(turn.as_ref(), EventMsg::PlanUpdate(args)) + .await; Ok(PlanToolOutput) } } -/// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render. -/// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other -/// than forcing it to come up and document a plan (TBD how that affects performance). -pub(crate) async fn handle_update_plan( - session: &Session, - turn_context: &TurnContext, - arguments: String, - _call_id: String, -) -> Result { - if turn_context.collaboration_mode.mode == ModeKind::Plan { - return Err(FunctionCallError::RespondToModel( - "update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(), - )); - } - let args = parse_update_plan_arguments(&arguments)?; - session - .send_event(turn_context, EventMsg::PlanUpdate(args)) - .await; - Ok("Plan updated".to_string()) -} - fn parse_update_plan_arguments(arguments: &str) -> Result { serde_json::from_str::(arguments).map_err(|e| { FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}")) diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 56facee658..7b49ec5803 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -8,12 +8,17 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; pub struct RequestPermissionsHandler; impl ToolHandler for RequestPermissionsHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("request_permissions") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 673a73bfb7..879a8955b4 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -13,6 +13,7 @@ use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; use codex_tools::RequestPluginInstallArgs; use codex_tools::RequestPluginInstallResult; +use codex_tools::ToolName; use codex_tools::all_requested_connectors_picked_up; use codex_tools::build_request_plugin_install_elicitation_request; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; @@ -37,6 +38,10 @@ pub struct RequestPluginInstallHandler; impl ToolHandler for RequestPluginInstallHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index eea6612762..cd00dc272c 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -8,6 +8,7 @@ use crate::tools::registry::ToolKind; use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; +use codex_tools::ToolName; use codex_tools::normalize_request_user_input_args; use codex_tools::request_user_input_unavailable_message; @@ -18,6 +19,10 @@ pub struct RequestUserInputHandler { impl ToolHandler for RequestUserInputHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index d498f36530..469c0a0799 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -1,17 +1,13 @@ -use codex_protocol::ThreadId; +use codex_features::Feature; use codex_protocol::models::ShellCommandToolCallParams; use codex_protocol::models::ShellToolCallParams; use serde_json::Value as JsonValue; use std::sync::Arc; -use crate::exec::ExecCapturePolicy; use crate::exec::ExecParams; -use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; use crate::function_tool::FunctionCallError; -use crate::maybe_emit_implicit_skill_invocation; use crate::session::turn_context::TurnContext; -use crate::shell::Shell; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -23,46 +19,45 @@ use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::implicit_granted_permissions; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; use crate::tools::runtimes::shell::ShellRequest; use crate::tools::runtimes::shell::ShellRuntime; use crate::tools::runtimes::shell::ShellRuntimeBackend; use crate::tools::sandboxing::ToolCtx; -use codex_features::Feature; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; -use codex_shell_command::is_safe_command::is_known_safe_command; -use codex_tools::ShellCommandBackendConfig; -pub struct ShellHandler; +mod container_exec; +mod local_shell; +mod shell_command; +mod shell_handler; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShellCommandBackend { - Classic, - ZshFork, +pub use container_exec::ContainerExecHandler; +pub use local_shell::LocalShellHandler; +pub use shell_command::ShellCommandHandler; +pub use shell_handler::ShellHandler; + +fn shell_function_payload_command(payload: &ToolPayload) -> Option { + let ToolPayload::Function { arguments } = payload else { + return None; + }; + + parse_arguments::(arguments) + .ok() + .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) } -pub struct ShellCommandHandler { - backend: ShellCommandBackend, -} +fn local_shell_payload_command(payload: &ToolPayload) -> Option { + let ToolPayload::LocalShell { params } = payload else { + return None; + }; -fn shell_payload_command(payload: &ToolPayload) -> Option { - match payload { - ToolPayload::Function { arguments } => parse_arguments::(arguments) - .ok() - .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)), - ToolPayload::LocalShell { params } => Some(codex_shell_command::parse_command::shlex_join( - ¶ms.command, - )), - _ => None, - } + Some(codex_shell_command::parse_command::shlex_join( + ¶ms.command, + )) } fn shell_command_payload_command(payload: &ToolPayload) -> Option { @@ -89,511 +84,224 @@ struct RunExecLikeArgs { shell_runtime_backend: ShellRuntimeBackend, } -impl ShellHandler { - fn to_exec_params( - params: &ShellToolCallParams, - turn_context: &TurnContext, - thread_id: ThreadId, - ) -> ExecParams { - ExecParams { - command: params.command.clone(), - cwd: turn_context.resolve_path(params.workdir.clone()), - expiration: params.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.network.clone(), - sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: params.justification.clone(), - arg0: None, - } - } +fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option { + shell_function_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ "command": command }), + }) } -impl ShellCommandHandler { - fn shell_runtime_backend(&self) -> ShellRuntimeBackend { - match self.backend { - ShellCommandBackend::Classic => ShellRuntimeBackend::ShellCommandClassic, - ShellCommandBackend::ZshFork => ShellRuntimeBackend::ShellCommandZshFork, +fn shell_function_post_tool_use_payload( + invocation: &ToolInvocation, + result: &FunctionToolOutput, +) -> Option { + let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let command = shell_function_payload_command(&invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::bash(), + tool_use_id: invocation.call_id.clone(), + tool_input: serde_json::json!({ "command": command }), + tool_response, + }) +} + +async fn run_exec_like(args: RunExecLikeArgs) -> Result { + let RunExecLikeArgs { + tool_name, + exec_params, + hook_command, + additional_permissions, + prefix_rule, + session, + turn, + tracker, + call_id, + freeform, + shell_runtime_backend, + } = args; + + let mut exec_params = exec_params; + let Some(turn_environment) = turn.environments.primary() else { + return Err(FunctionCallError::RespondToModel( + "shell is unavailable in this session".to_string(), + )); + }; + let fs = turn_environment.environment.get_filesystem(); + + let dependency_env = session.dependency_env().await; + if !dependency_env.is_empty() { + exec_params.env.extend(dependency_env.clone()); + } + + let mut explicit_env_overrides = turn.shell_environment_policy.r#set.clone(); + for key in dependency_env.keys() { + if let Some(value) = exec_params.env.get(key) { + explicit_env_overrides.insert(key.clone(), value.clone()); } } - fn resolve_use_login_shell( - login: Option, - allow_login_shell: bool, - ) -> Result { - if !allow_login_shell && login == Some(true) { - return Err(FunctionCallError::RespondToModel( - "login shell is disabled by config; omit `login` or set it to false.".to_string(), - )); - } + let exec_permission_approvals_enabled = + session.features().enabled(Feature::ExecPermissionApprovals); + let requested_additional_permissions = additional_permissions.clone(); + let effective_additional_permissions = apply_granted_turn_permissions( + session.as_ref(), + turn.cwd.as_path(), + exec_params.sandbox_permissions, + additional_permissions, + ) + .await; + let additional_permissions_allowed = exec_permission_approvals_enabled + || (session.features().enabled(Feature::RequestPermissionsTool) + && effective_additional_permissions.permissions_preapproved); + let normalized_additional_permissions = implicit_granted_permissions( + exec_params.sandbox_permissions, + requested_additional_permissions.as_ref(), + &effective_additional_permissions, + ) + .map_or_else( + || { + normalize_and_validate_additional_permissions( + additional_permissions_allowed, + turn.approval_policy.value(), + effective_additional_permissions.sandbox_permissions, + effective_additional_permissions.additional_permissions, + effective_additional_permissions.permissions_preapproved, + &exec_params.cwd, + ) + }, + |permissions| Ok(Some(permissions)), + ) + .map_err(FunctionCallError::RespondToModel)?; - Ok(login.unwrap_or(allow_login_shell)) - } - - fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec { - shell.derive_exec_args(command, use_login_shell) - } - - fn to_exec_params( - params: &ShellCommandToolCallParams, - session: &crate::session::session::Session, - turn_context: &TurnContext, - thread_id: ThreadId, - allow_login_shell: bool, - ) -> Result { - let shell = session.user_shell(); - let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; - let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - - Ok(ExecParams { - command, - cwd: turn_context.resolve_path(params.workdir.clone()), - expiration: params.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.network.clone(), - sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: params.justification.clone(), - arg0: None, - }) - } -} - -impl From for ShellCommandHandler { - fn from(config: ShellCommandBackendConfig) -> Self { - let backend = match config { - ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic, - ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork, - }; - Self { backend } - } -} - -impl ToolHandler for ShellHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!( - payload, - ToolPayload::Function { .. } | ToolPayload::LocalShell { .. } + // Approval policy guard for explicit escalation in non-OnRequest modes. + // Sticky turn permissions have already been approved, so they should + // continue through the normal exec approval flow for the command. + if effective_additional_permissions + .sandbox_permissions + .requests_sandbox_override() + && !effective_additional_permissions.permissions_preapproved + && !matches!( + turn.approval_policy.value(), + codex_protocol::protocol::AskForApproval::OnRequest ) + { + let approval_policy = turn.approval_policy.value(); + return Err(FunctionCallError::RespondToModel(format!( + "approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}" + ))); } - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - match &invocation.payload { - ToolPayload::Function { arguments } => { - serde_json::from_str::(arguments) - .map(|params| !is_known_safe_command(¶ms.command)) - .unwrap_or(true) - } - ToolPayload::LocalShell { params } => !is_known_safe_command(¶ms.command), - _ => true, // unknown payloads => assume mutating - } + // Intercept apply_patch if present. + if let Some(output) = intercept_apply_patch( + &exec_params.command, + &exec_params.cwd, + fs.as_ref(), + session.clone(), + turn.clone(), + Some(&tracker), + &call_id, + tool_name.as_str(), + ) + .await? + { + return Ok(output); } - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) - } + let source = ExecCommandSource::Agent; + let emitter = ToolEmitter::shell( + exec_params.command.clone(), + exec_params.cwd.clone(), + source, + freeform, + ); + let event_ctx = ToolEventCtx::new( + session.as_ref(), + turn.as_ref(), + &call_id, + /*turn_diff_tracker*/ None, + ); + emitter.begin(event_ctx).await; - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - tool_name, - payload, - .. - } = invocation; - - match payload { - ToolPayload::Function { arguments } => { - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - Self::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - Self::run_exec_like(RunExecLikeArgs { - tool_name: tool_name.display(), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } - ToolPayload::LocalShell { params } => { - let exec_params = - Self::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - Self::run_exec_like(RunExecLikeArgs { - tool_name: tool_name.display(), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: None, - prefix_rule: None, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } - _ => Err(FunctionCallError::RespondToModel(format!( - "unsupported payload for shell handler: {}", - tool_name.display() - ))), - } - } -} - -impl ToolHandler for ShellCommandHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - let ToolPayload::Function { arguments } = &invocation.payload else { - return true; - }; - - serde_json::from_str::(arguments) - .map(|params| { - let use_login_shell = match Self::resolve_use_login_shell( - params.login, - invocation.turn.tools_config.allow_login_shell, - ) { - Ok(use_login_shell) => use_login_shell, - Err(_) => return true, - }; - let shell = invocation.session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - !is_known_safe_command(&command) - }) - .unwrap_or(true) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_command_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - tool_name, - payload, - .. - } = invocation; - - let ToolPayload::Function { arguments } = payload else { - return Err(FunctionCallError::RespondToModel(format!( - "unsupported payload for shell_command handler: {}", - tool_name.display() - ))); - }; - - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let workdir = turn.resolve_path(params.workdir.clone()); - maybe_emit_implicit_skill_invocation( - session.as_ref(), - turn.as_ref(), - ¶ms.command, - &workdir, - ) - .await; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = Self::to_exec_params( - ¶ms, - session.as_ref(), - turn.as_ref(), - session.conversation_id, - turn.tools_config.allow_login_shell, - )?; - ShellHandler::run_exec_like(RunExecLikeArgs { - tool_name: tool_name.display(), - exec_params, - hook_command: params.command, - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: true, - shell_runtime_backend: self.shell_runtime_backend(), - }) - .await - } -} - -impl ShellHandler { - async fn run_exec_like(args: RunExecLikeArgs) -> Result { - let RunExecLikeArgs { - tool_name, - exec_params, - hook_command, - additional_permissions, - prefix_rule, - session, - turn, - tracker, - call_id, - freeform, - shell_runtime_backend, - } = args; - - let mut exec_params = exec_params; - let Some(turn_environment) = turn.environments.primary() else { - return Err(FunctionCallError::RespondToModel( - "shell is unavailable in this session".to_string(), - )); - }; - let fs = turn_environment.environment.get_filesystem(); - - let dependency_env = session.dependency_env().await; - if !dependency_env.is_empty() { - exec_params.env.extend(dependency_env.clone()); - } - - let mut explicit_env_overrides = turn.shell_environment_policy.r#set.clone(); - for key in dependency_env.keys() { - if let Some(value) = exec_params.env.get(key) { - explicit_env_overrides.insert(key.clone(), value.clone()); - } - } - - let exec_permission_approvals_enabled = - session.features().enabled(Feature::ExecPermissionApprovals); - let requested_additional_permissions = additional_permissions.clone(); - let effective_additional_permissions = apply_granted_turn_permissions( - session.as_ref(), - turn.cwd.as_path(), - exec_params.sandbox_permissions, - additional_permissions, - ) - .await; - let additional_permissions_allowed = exec_permission_approvals_enabled - || (session.features().enabled(Feature::RequestPermissionsTool) - && effective_additional_permissions.permissions_preapproved); - let normalized_additional_permissions = implicit_granted_permissions( - exec_params.sandbox_permissions, - requested_additional_permissions.as_ref(), - &effective_additional_permissions, - ) - .map_or_else( - || { - normalize_and_validate_additional_permissions( - additional_permissions_allowed, - turn.approval_policy.value(), - effective_additional_permissions.sandbox_permissions, - effective_additional_permissions.additional_permissions, - effective_additional_permissions.permissions_preapproved, - &exec_params.cwd, - ) + let file_system_sandbox_policy = turn.file_system_sandbox_policy(); + let exec_approval_requirement = session + .services + .exec_policy + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &exec_params.command, + approval_policy: turn.approval_policy.value(), + permission_profile: turn.permission_profile(), + file_system_sandbox_policy: &file_system_sandbox_policy, + sandbox_cwd: turn.cwd.as_path(), + sandbox_permissions: if effective_additional_permissions.permissions_preapproved { + codex_protocol::models::SandboxPermissions::UseDefault + } else { + effective_additional_permissions.sandbox_permissions }, - |permissions| Ok(Some(permissions)), - ) - .map_err(FunctionCallError::RespondToModel)?; - - // Approval policy guard for explicit escalation in non-OnRequest modes. - // Sticky turn permissions have already been approved, so they should - // continue through the normal exec approval flow for the command. - if effective_additional_permissions - .sandbox_permissions - .requests_sandbox_override() - && !effective_additional_permissions.permissions_preapproved - && !matches!( - turn.approval_policy.value(), - codex_protocol::protocol::AskForApproval::OnRequest - ) - { - let approval_policy = turn.approval_policy.value(); - return Err(FunctionCallError::RespondToModel(format!( - "approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}" - ))); - } - - // Intercept apply_patch if present. - if let Some(output) = intercept_apply_patch( - &exec_params.command, - &exec_params.cwd, - fs.as_ref(), - session.clone(), - turn.clone(), - Some(&tracker), - &call_id, - tool_name.as_str(), - ) - .await? - { - return Ok(output); - } - - let source = ExecCommandSource::Agent; - let emitter = ToolEmitter::shell( - exec_params.command.clone(), - exec_params.cwd.clone(), - source, - freeform, - ); - let event_ctx = ToolEventCtx::new( - session.as_ref(), - turn.as_ref(), - &call_id, - /*turn_diff_tracker*/ None, - ); - emitter.begin(event_ctx).await; - - let file_system_sandbox_policy = turn.file_system_sandbox_policy(); - let exec_approval_requirement = session - .services - .exec_policy - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &exec_params.command, - approval_policy: turn.approval_policy.value(), - permission_profile: turn.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, - sandbox_cwd: turn.cwd.as_path(), - sandbox_permissions: if effective_additional_permissions.permissions_preapproved { - codex_protocol::models::SandboxPermissions::UseDefault - } else { - effective_additional_permissions.sandbox_permissions - }, - prefix_rule, - }) - .await; - - let req = ShellRequest { - command: exec_params.command.clone(), - hook_command, - cwd: exec_params.cwd.clone(), - timeout_ms: exec_params.expiration.timeout_ms(), - env: exec_params.env.clone(), - explicit_env_overrides, - network: exec_params.network.clone(), - sandbox_permissions: effective_additional_permissions.sandbox_permissions, - additional_permissions: normalized_additional_permissions, - #[cfg(unix)] - additional_permissions_preapproved: effective_additional_permissions - .permissions_preapproved, - justification: exec_params.justification.clone(), - exec_approval_requirement, - }; - let mut orchestrator = ToolOrchestrator::new(); - let mut runtime = { - use ShellRuntimeBackend::*; - match shell_runtime_backend { - Generic => ShellRuntime::new(), - backend @ (ShellCommandClassic | ShellCommandZshFork) => { - ShellRuntime::for_shell_command(backend) - } - } - }; - let tool_ctx = ToolCtx { - session: session.clone(), - turn: turn.clone(), - call_id: call_id.clone(), - tool_name, - }; - let out = orchestrator - .run( - &mut runtime, - &req, - &tool_ctx, - &turn, - turn.approval_policy.value(), - ) - .await - .map(|result| result.output); - let event_ctx = ToolEventCtx::new( - session.as_ref(), - turn.as_ref(), - &call_id, - /*turn_diff_tracker*/ None, - ); - let post_tool_use_response = out - .as_ref() - .ok() - .map(|output| crate::tools::format_exec_output_str(output, turn.truncation_policy)) - .map(JsonValue::String); - let content = emitter.finish(event_ctx, out).await?; - Ok(FunctionToolOutput { - body: vec![ - codex_protocol::models::FunctionCallOutputContentItem::InputText { text: content }, - ], - success: Some(true), - post_tool_use_response, + prefix_rule, }) - } + .await; + + let req = ShellRequest { + command: exec_params.command.clone(), + hook_command, + cwd: exec_params.cwd.clone(), + timeout_ms: exec_params.expiration.timeout_ms(), + env: exec_params.env.clone(), + explicit_env_overrides, + network: exec_params.network.clone(), + sandbox_permissions: effective_additional_permissions.sandbox_permissions, + additional_permissions: normalized_additional_permissions, + #[cfg(unix)] + additional_permissions_preapproved: effective_additional_permissions + .permissions_preapproved, + justification: exec_params.justification.clone(), + exec_approval_requirement, + }; + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = { + use ShellRuntimeBackend::*; + match shell_runtime_backend { + Generic => ShellRuntime::new(), + backend @ (ShellCommandClassic | ShellCommandZshFork) => { + ShellRuntime::for_shell_command(backend) + } + } + }; + let tool_ctx = ToolCtx { + session: session.clone(), + turn: turn.clone(), + call_id: call_id.clone(), + tool_name, + }; + let out = orchestrator + .run( + &mut runtime, + &req, + &tool_ctx, + &turn, + turn.approval_policy.value(), + ) + .await + .map(|result| result.output); + let event_ctx = ToolEventCtx::new( + session.as_ref(), + turn.as_ref(), + &call_id, + /*turn_diff_tracker*/ None, + ); + let post_tool_use_response = out + .as_ref() + .ok() + .map(|output| crate::tools::format_exec_output_str(output, turn.truncation_policy)) + .map(JsonValue::String); + let content = emitter.finish(event_ctx, out).await?; + Ok(FunctionToolOutput { + body: vec![ + codex_protocol::models::FunctionCallOutputContentItem::InputText { text: content }, + ], + success: Some(true), + post_tool_use_response, + }) } #[cfg(test)] diff --git a/codex-rs/core/src/tools/handlers/shell/container_exec.rs b/codex-rs/core/src/tools/handlers/shell/container_exec.rs new file mode 100644 index 0000000000..70bf56fb4d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/shell/container_exec.rs @@ -0,0 +1,101 @@ +use codex_protocol::models::ShellToolCallParams; +use codex_shell_command::is_safe_command::is_known_safe_command; +use codex_tools::ToolName; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_workdir_base_path; +use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::runtimes::shell::ShellRuntimeBackend; + +use super::RunExecLikeArgs; +use super::run_exec_like; +use super::shell_function_post_tool_use_payload; +use super::shell_function_pre_tool_use_payload; +use super::shell_handler::ShellHandler; + +pub struct ContainerExecHandler; + +impl ToolHandler for ContainerExecHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("container.exec") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| !is_known_safe_command(¶ms.command)) + .unwrap_or(true) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + shell_function_pre_tool_use_payload(invocation) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + shell_function_post_tool_use_payload(invocation, result) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "unsupported payload for container.exec handler".to_string(), + )); + } + }; + + let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; + let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + let prefix_rule = params.prefix_rule.clone(); + let exec_params = + ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); + run_exec_like(RunExecLikeArgs { + tool_name: "container.exec".to_string(), + exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), + additional_permissions: params.additional_permissions.clone(), + prefix_rule, + session, + turn, + tracker, + call_id, + freeform: false, + shell_runtime_backend: ShellRuntimeBackend::Generic, + }) + .await + } +} diff --git a/codex-rs/core/src/tools/handlers/shell/local_shell.rs b/codex-rs/core/src/tools/handlers/shell/local_shell.rs new file mode 100644 index 0000000000..bdb70e9368 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/shell/local_shell.rs @@ -0,0 +1,102 @@ +use codex_shell_command::is_safe_command::is_known_safe_command; +use codex_tools::ToolName; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::hook_names::HookToolName; +use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::runtimes::shell::ShellRuntimeBackend; + +use super::RunExecLikeArgs; +use super::local_shell_payload_command; +use super::run_exec_like; +use super::shell_handler::ShellHandler; + +pub struct LocalShellHandler; + +impl ToolHandler for LocalShellHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("local_shell") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::LocalShell { .. }) + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::LocalShell { params } = &invocation.payload else { + return true; + }; + + !is_known_safe_command(¶ms.command) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + local_shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ "command": command }), + }) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let command = local_shell_payload_command(&invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::bash(), + tool_use_id: invocation.call_id.clone(), + tool_input: serde_json::json!({ "command": command }), + tool_response, + }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + payload, + .. + } = invocation; + + let ToolPayload::LocalShell { params } = payload else { + return Err(FunctionCallError::RespondToModel( + "unsupported payload for local_shell handler".to_string(), + )); + }; + + let exec_params = + ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); + run_exec_like(RunExecLikeArgs { + tool_name: "local_shell".to_string(), + exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), + additional_permissions: None, + prefix_rule: None, + session, + turn, + tracker, + call_id, + freeform: false, + shell_runtime_backend: ShellRuntimeBackend::Generic, + }) + .await + } +} diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs new file mode 100644 index 0000000000..69f965b51e --- /dev/null +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -0,0 +1,215 @@ +use codex_protocol::ThreadId; +use codex_protocol::models::ShellCommandToolCallParams; +use codex_shell_command::is_safe_command::is_known_safe_command; +use codex_tools::ShellCommandBackendConfig; +use codex_tools::ToolName; + +use crate::exec::ExecCapturePolicy; +use crate::exec::ExecParams; +use crate::exec_env::create_env; +use crate::function_tool::FunctionCallError; +use crate::maybe_emit_implicit_skill_invocation; +use crate::session::turn_context::TurnContext; +use crate::shell::Shell; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_workdir_base_path; +use crate::tools::hook_names::HookToolName; +use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::runtimes::shell::ShellRuntimeBackend; + +use super::RunExecLikeArgs; +use super::run_exec_like; +use super::shell_command_payload_command; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShellCommandBackend { + Classic, + ZshFork, +} + +pub struct ShellCommandHandler { + backend: ShellCommandBackend, +} + +impl ShellCommandHandler { + fn shell_runtime_backend(&self) -> ShellRuntimeBackend { + match self.backend { + ShellCommandBackend::Classic => ShellRuntimeBackend::ShellCommandClassic, + ShellCommandBackend::ZshFork => ShellRuntimeBackend::ShellCommandZshFork, + } + } + + pub(super) fn resolve_use_login_shell( + login: Option, + allow_login_shell: bool, + ) -> Result { + if !allow_login_shell && login == Some(true) { + return Err(FunctionCallError::RespondToModel( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + )); + } + + Ok(login.unwrap_or(allow_login_shell)) + } + + pub(super) fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec { + shell.derive_exec_args(command, use_login_shell) + } + + pub(super) fn to_exec_params( + params: &ShellCommandToolCallParams, + session: &crate::session::session::Session, + turn_context: &TurnContext, + thread_id: ThreadId, + allow_login_shell: bool, + ) -> Result { + let shell = session.user_shell(); + let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); + + Ok(ExecParams { + command, + cwd: turn_context.resolve_path(params.workdir.clone()), + expiration: params.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), + network: turn_context.network.clone(), + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), + windows_sandbox_level: turn_context.windows_sandbox_level, + windows_sandbox_private_desktop: turn_context + .config + .permissions + .windows_sandbox_private_desktop, + justification: params.justification.clone(), + arg0: None, + }) + } +} + +impl From for ShellCommandHandler { + fn from(config: ShellCommandBackendConfig) -> Self { + let backend = match config { + ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic, + ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork, + }; + Self { backend } + } +} + +impl ToolHandler for ShellCommandHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("shell_command") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| { + let use_login_shell = match Self::resolve_use_login_shell( + params.login, + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(use_login_shell) => use_login_shell, + Err(_) => return true, + }; + let shell = invocation.session.user_shell(); + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); + !is_known_safe_command(&command) + }) + .unwrap_or(true) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ "command": command }), + }) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let command = shell_command_payload_command(&invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::bash(), + tool_use_id: invocation.call_id.clone(), + tool_input: serde_json::json!({ "command": command }), + tool_response, + }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + payload, + .. + } = invocation; + + let ToolPayload::Function { arguments } = payload else { + return Err(FunctionCallError::RespondToModel(format!( + "unsupported payload for shell_command handler: {}", + self.tool_name().display() + ))); + }; + + let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; + let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + let workdir = turn.resolve_path(params.workdir.clone()); + maybe_emit_implicit_skill_invocation( + session.as_ref(), + turn.as_ref(), + ¶ms.command, + &workdir, + ) + .await; + let prefix_rule = params.prefix_rule.clone(); + let exec_params = Self::to_exec_params( + ¶ms, + session.as_ref(), + turn.as_ref(), + session.conversation_id, + turn.tools_config.allow_login_shell, + )?; + run_exec_like(RunExecLikeArgs { + tool_name: self.tool_name().display(), + exec_params, + hook_command: params.command, + additional_permissions: params.additional_permissions.clone(), + prefix_rule, + session, + turn, + tracker, + call_id, + freeform: true, + shell_runtime_backend: self.shell_runtime_backend(), + }) + .await + } +} diff --git a/codex-rs/core/src/tools/handlers/shell/shell_handler.rs b/codex-rs/core/src/tools/handlers/shell/shell_handler.rs new file mode 100644 index 0000000000..30220d3db0 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/shell/shell_handler.rs @@ -0,0 +1,130 @@ +use codex_protocol::ThreadId; +use codex_protocol::models::ShellToolCallParams; +use codex_shell_command::is_safe_command::is_known_safe_command; +use codex_tools::ToolName; + +use crate::exec::ExecCapturePolicy; +use crate::exec::ExecParams; +use crate::exec_env::create_env; +use crate::function_tool::FunctionCallError; +use crate::session::turn_context::TurnContext; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_workdir_base_path; +use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::runtimes::shell::ShellRuntimeBackend; + +use super::RunExecLikeArgs; +use super::run_exec_like; +use super::shell_function_post_tool_use_payload; +use super::shell_function_pre_tool_use_payload; + +pub struct ShellHandler; + +impl ShellHandler { + pub(super) fn to_exec_params( + params: &ShellToolCallParams, + turn_context: &TurnContext, + thread_id: ThreadId, + ) -> ExecParams { + ExecParams { + command: params.command.clone(), + cwd: turn_context.resolve_path(params.workdir.clone()), + expiration: params.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), + network: turn_context.network.clone(), + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), + windows_sandbox_level: turn_context.windows_sandbox_level, + windows_sandbox_private_desktop: turn_context + .config + .permissions + .windows_sandbox_private_desktop, + justification: params.justification.clone(), + arg0: None, + } + } +} + +impl ToolHandler for ShellHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("shell") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| !is_known_safe_command(¶ms.command)) + .unwrap_or(true) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + shell_function_pre_tool_use_payload(invocation) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + shell_function_post_tool_use_payload(invocation, result) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "unsupported payload for shell handler".to_string(), + )); + } + }; + + let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; + let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + let prefix_rule = params.prefix_rule.clone(); + let exec_params = + ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); + run_exec_like(RunExecLikeArgs { + tool_name: "shell".to_string(), + exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), + additional_permissions: params.additional_permissions.clone(), + prefix_rule, + session, + turn, + tracker, + call_id, + freeform: false, + shell_runtime_backend: ShellRuntimeBackend::Generic, + }) + .await + } +} diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 49e2cf8f75..a7e6dae35c 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -16,8 +16,8 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::ShellHandler; use crate::tools::hook_names::HookToolName; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; @@ -204,7 +204,7 @@ fn shell_command_handler_rejects_login_when_disallowed() { } #[tokio::test] -async fn shell_pre_tool_use_payload_uses_joined_command() { +async fn local_shell_pre_tool_use_payload_uses_joined_command() { let payload = ToolPayload::LocalShell { params: codex_protocol::models::ShellToolCallParams { command: vec![ @@ -215,13 +215,13 @@ async fn shell_pre_tool_use_payload_uses_joined_command() { workdir: None, timeout_ms: None, sandbox_permissions: None, - prefix_rule: None, additional_permissions: None, + prefix_rule: None, justification: None, }, }; let (session, turn) = make_session_and_context().await; - let handler = ShellHandler; + let handler = LocalShellHandler; assert_eq!( handler.pre_tool_use_payload(&ToolInvocation { @@ -230,7 +230,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() { cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-41".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("local_shell"), source: crate::tools::context::ToolCallSource::Direct, payload, }), @@ -247,9 +247,7 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() { arguments: json!({ "command": "printf shell command" }).to_string(), }; let (session, turn) = make_session_and_context().await; - let handler = ShellCommandHandler { - backend: super::ShellCommandBackend::Classic, - }; + let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic); assert_eq!( handler.pre_tool_use_payload(&ToolInvocation { @@ -279,9 +277,7 @@ async fn build_post_tool_use_payload_uses_tool_output_wire_value() { success: Some(true), post_tool_use_response: Some(json!("shell output")), }; - let handler = ShellCommandHandler { - backend: super::ShellCommandBackend::Classic, - }; + let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic); let (session, turn) = make_session_and_context().await; let invocation = ToolInvocation { session: session.into(), diff --git a/codex-rs/core/src/tools/handlers/test_sync.rs b/codex-rs/core/src/tools/handlers/test_sync.rs index ad2647243a..e04400d17e 100644 --- a/codex-rs/core/src/tools/handlers/test_sync.rs +++ b/codex-rs/core/src/tools/handlers/test_sync.rs @@ -15,6 +15,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; pub struct TestSyncHandler; @@ -56,6 +57,10 @@ fn barrier_map() -> &'static tokio::sync::Mutex> { impl ToolHandler for TestSyncHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("test_sync_tool") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 67bc7b7f27..59deb54116 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -12,6 +12,7 @@ use bm25::SearchEngineBuilder; use codex_tools::LoadableToolSpec; use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT; use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolName; use codex_tools::coalesce_loadable_tool_specs; use std::collections::HashMap; @@ -44,6 +45,10 @@ impl ToolSearchHandler { impl ToolHandler for ToolSearchHandler { type Output = ToolSearchOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(TOOL_SEARCH_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/unavailable_tool.rs b/codex-rs/core/src/tools/handlers/unavailable_tool.rs index eb00cf8ff1..64bb200581 100644 --- a/codex-rs/core/src/tools/handlers/unavailable_tool.rs +++ b/codex-rs/core/src/tools/handlers/unavailable_tool.rs @@ -4,8 +4,17 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; -pub struct UnavailableToolHandler; +pub struct UnavailableToolHandler { + tool_name: ToolName, +} + +impl UnavailableToolHandler { + pub fn new(tool_name: ToolName) -> Self { + Self { tool_name } + } +} pub(crate) fn unavailable_tool_message( tool_name: impl std::fmt::Display, @@ -19,19 +28,21 @@ pub(crate) fn unavailable_tool_message( impl ToolHandler for UnavailableToolHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - tool_name, payload, .. - } = invocation; + let ToolInvocation { payload, .. } = invocation; match payload { ToolPayload::Function { .. } => Ok(FunctionToolOutput::from_text( unavailable_tool_message( - tool_name.display(), + self.tool_name.display(), "Retry after the tool becomes available or ask the user to re-enable it.", ), Some(false), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 563ee8d835..80e85ccd47 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,5 +1,3 @@ -use crate::function_tool::FunctionCallError; -use crate::maybe_emit_implicit_skill_invocation; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; @@ -7,40 +5,24 @@ use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; -use crate::tools::handlers::apply_granted_turn_permissions; -use crate::tools::handlers::apply_patch::intercept_apply_patch; -use crate::tools::handlers::implicit_granted_permissions; -use crate::tools::handlers::normalize_and_validate_additional_permissions; -use crate::tools::handlers::parse_arguments; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; -use crate::unified_exec::ExecCommandRequest; -use crate::unified_exec::UnifiedExecContext; -use crate::unified_exec::UnifiedExecError; -use crate::unified_exec::UnifiedExecProcessManager; -use crate::unified_exec::WriteStdinRequest; -use crate::unified_exec::generate_chunk_id; use crate::unified_exec::resolve_max_tokens; -use codex_features::Feature; -use codex_otel::SessionTelemetry; -use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; use codex_protocol::models::AdditionalPermissionProfile; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::TerminalInteractionEvent; -use codex_shell_command::is_safe_command::is_known_safe_command; use codex_tools::UnifiedExecShellMode; use codex_utils_output_truncation::TruncationPolicy; -use codex_utils_output_truncation::approx_token_count; use serde::Deserialize; use std::path::PathBuf; use std::sync::Arc; -pub struct UnifiedExecHandler; +#[cfg(test)] +use crate::tools::handlers::parse_arguments; + +mod exec_command; +mod write_stdin; + +pub use exec_command::ExecCommandHandler; +pub use write_stdin::WriteStdinHandler; #[derive(Debug, Deserialize)] pub(crate) struct ExecCommandArgs { @@ -68,15 +50,13 @@ pub(crate) struct ExecCommandArgs { } #[derive(Debug, Deserialize)] -struct WriteStdinArgs { - // The model is trained on `session_id`. - session_id: i32, +struct ExecCommandEnvironmentArgs { #[serde(default)] - chars: String, - #[serde(default = "default_write_stdin_yield_time_ms")] - yield_time_ms: u64, + environment_id: Option, + // Keep this raw until after environment selection; relative paths must be + // resolved against the selected environment cwd, not the process cwd. #[serde(default)] - max_output_tokens: Option, + workdir: Option, } fn default_exec_yield_time_ms() -> u64 { @@ -98,331 +78,27 @@ fn effective_max_output_tokens( resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget()) } -impl ToolHandler for UnifiedExecHandler { - type Output = ExecCommandToolOutput; +fn post_unified_exec_tool_use_payload( + invocation: &ToolInvocation, + result: &ExecCommandToolOutput, +) -> Option { + let ToolPayload::Function { .. } = &invocation.payload else { + return None; + }; - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - let ToolPayload::Function { arguments } = &invocation.payload else { - tracing::error!( - "This should never happen, invocation payload is wrong: {:?}", - invocation.payload - ); - return true; - }; - - let Ok(params) = parse_arguments::(arguments) else { - return true; - }; - let command = match get_command( - ¶ms, - invocation.session.user_shell(), - &invocation.turn.tools_config.unified_exec_shell_mode, - invocation.turn.tools_config.allow_login_shell, - ) { - Ok(command) => command, - Err(_) => return true, - }; - !is_known_safe_command(&command) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - if invocation.tool_name.namespace.is_some() - || invocation.tool_name.name.as_str() != "exec_command" - { - return None; - } - - let ToolPayload::Function { arguments } = &invocation.payload else { - return None; - }; - - parse_arguments::(arguments) - .ok() - .map(|args| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": args.cmd }), - }) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let ToolPayload::Function { .. } = &invocation.payload else { - return None; - }; - - let command = result.hook_command.clone()?; - let tool_use_id = if result.event_call_id.is_empty() { - invocation.call_id.clone() - } else { - result.event_call_id.clone() - }; - let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id, - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - tool_name, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "unified_exec handler received unsupported payload".to_string(), - )); - } - }; - - let Some(turn_environment) = turn.environments.primary() else { - return Err(FunctionCallError::RespondToModel( - "unified exec is unavailable in this session".to_string(), - )); - }; - let fs = turn_environment.environment.get_filesystem(); - - let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager; - let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); - - let response = match tool_name.name.as_str() { - "exec_command" => { - let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?; - let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; - let hook_command = args.cmd.clone(); - let workdir = context.turn.resolve_path(args.workdir.clone()); - maybe_emit_implicit_skill_invocation( - session.as_ref(), - context.turn.as_ref(), - &hook_command, - &workdir, - ) - .await; - let process_id = manager.allocate_process_id().await; - let command = get_command( - &args, - session.user_shell(), - &turn.tools_config.unified_exec_shell_mode, - turn.tools_config.allow_login_shell, - ) - .map_err(FunctionCallError::RespondToModel)?; - let command_for_display = codex_shell_command::parse_command::shlex_join(&command); - - let ExecCommandArgs { - workdir, - tty, - yield_time_ms, - max_output_tokens, - sandbox_permissions, - additional_permissions, - justification, - prefix_rule, - .. - } = args; - let max_output_tokens = - effective_max_output_tokens(max_output_tokens, turn.truncation_policy); - - let exec_permission_approvals_enabled = - session.features().enabled(Feature::ExecPermissionApprovals); - let requested_additional_permissions = additional_permissions.clone(); - let effective_additional_permissions = apply_granted_turn_permissions( - context.session.as_ref(), - context.turn.cwd.as_path(), - sandbox_permissions, - additional_permissions, - ) - .await; - let additional_permissions_allowed = exec_permission_approvals_enabled - || (session.features().enabled(Feature::RequestPermissionsTool) - && effective_additional_permissions.permissions_preapproved); - - // Sticky turn permissions have already been approved, so they should - // continue through the normal exec approval flow for the command. - if effective_additional_permissions - .sandbox_permissions - .requests_sandbox_override() - && !effective_additional_permissions.permissions_preapproved - && !matches!( - context.turn.approval_policy.value(), - codex_protocol::protocol::AskForApproval::OnRequest - ) - { - let approval_policy = context.turn.approval_policy.value(); - manager.release_process_id(process_id).await; - return Err(FunctionCallError::RespondToModel(format!( - "approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}" - ))); - } - - let workdir = workdir.filter(|value| !value.is_empty()); - - let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir))); - let cwd = workdir.clone().unwrap_or(cwd); - let normalized_additional_permissions = match implicit_granted_permissions( - sandbox_permissions, - requested_additional_permissions.as_ref(), - &effective_additional_permissions, - ) - .map_or_else( - || { - normalize_and_validate_additional_permissions( - additional_permissions_allowed, - context.turn.approval_policy.value(), - effective_additional_permissions.sandbox_permissions, - effective_additional_permissions.additional_permissions, - effective_additional_permissions.permissions_preapproved, - &cwd, - ) - }, - |permissions| Ok(Some(permissions)), - ) { - Ok(normalized) => normalized, - Err(err) => { - manager.release_process_id(process_id).await; - return Err(FunctionCallError::RespondToModel(err)); - } - }; - - if let Some(output) = intercept_apply_patch( - &command, - &cwd, - fs.as_ref(), - context.session.clone(), - context.turn.clone(), - Some(&tracker), - &context.call_id, - &tool_name.name, - ) - .await? - { - manager.release_process_id(process_id).await; - return Ok(ExecCommandToolOutput { - event_call_id: String::new(), - chunk_id: String::new(), - wall_time: std::time::Duration::ZERO, - raw_output: output.into_text().into_bytes(), - max_output_tokens: Some(max_output_tokens), - process_id: None, - exit_code: None, - original_token_count: None, - hook_command: None, - }); - } - - emit_unified_exec_tty_metric(&turn.session_telemetry, tty); - match manager - .exec_command( - ExecCommandRequest { - command, - hook_command: hook_command.clone(), - process_id, - yield_time_ms, - max_output_tokens: Some(max_output_tokens), - workdir, - network: context.turn.network.clone(), - tty, - sandbox_permissions: effective_additional_permissions - .sandbox_permissions, - additional_permissions: normalized_additional_permissions, - additional_permissions_preapproved: effective_additional_permissions - .permissions_preapproved, - justification, - prefix_rule, - }, - &context, - ) - .await - { - Ok(response) => response, - Err(UnifiedExecError::SandboxDenied { output, .. }) => { - let output_text = output.aggregated_output.text; - let original_token_count = approx_token_count(&output_text); - ExecCommandToolOutput { - event_call_id: context.call_id.clone(), - chunk_id: generate_chunk_id(), - wall_time: output.duration, - raw_output: output_text.into_bytes(), - max_output_tokens: Some(max_output_tokens), - // Sandbox denial is terminal, so there is no live - // process for write_stdin to resume. - process_id: None, - exit_code: Some(output.exit_code), - original_token_count: Some(original_token_count), - hook_command: Some(hook_command), - } - } - Err(err) => { - return Err(FunctionCallError::RespondToModel(format!( - "exec_command failed for `{command_for_display}`: {err:?}" - ))); - } - } - } - "write_stdin" => { - let args: WriteStdinArgs = parse_arguments(&arguments)?; - let max_output_tokens = - effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy); - let response = manager - .write_stdin(WriteStdinRequest { - process_id: args.session_id, - input: &args.chars, - yield_time_ms: args.yield_time_ms, - max_output_tokens: Some(max_output_tokens), - }) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) - })?; - - let interaction = TerminalInteractionEvent { - call_id: response.event_call_id.clone(), - process_id: args.session_id.to_string(), - stdin: args.chars.clone(), - }; - session - .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) - .await; - - response - } - other => { - return Err(FunctionCallError::RespondToModel(format!( - "unsupported unified exec function {other}" - ))); - } - }; - - Ok(response) - } -} - -fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { - session_telemetry.counter( - TOOL_CALL_UNIFIED_EXEC_METRIC, - /*inc*/ 1, - &[("tty", if tty { "true" } else { "false" })], - ); + let command = result.hook_command.clone()?; + let tool_use_id = if result.event_call_id.is_empty() { + invocation.call_id.clone() + } else { + result.event_call_id.clone() + }; + let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::bash(), + tool_use_id, + tool_input: serde_json::json!({ "command": command }), + tool_response, + }) } pub(crate) fn get_command( 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 new file mode 100644 index 0000000000..75ae3fea29 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -0,0 +1,309 @@ +use std::sync::Arc; + +use crate::function_tool::FunctionCallError; +use crate::maybe_emit_implicit_skill_invocation; +use crate::tools::context::ExecCommandToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::apply_granted_turn_permissions; +use crate::tools::handlers::apply_patch::intercept_apply_patch; +use crate::tools::handlers::implicit_granted_permissions; +use crate::tools::handlers::normalize_and_validate_additional_permissions; +use crate::tools::handlers::parse_arguments; +use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_tool_environment; +use crate::tools::hook_names::HookToolName; +use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::PreToolUsePayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::unified_exec::ExecCommandRequest; +use crate::unified_exec::UnifiedExecContext; +use crate::unified_exec::UnifiedExecError; +use crate::unified_exec::UnifiedExecProcessManager; +use crate::unified_exec::generate_chunk_id; +use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC; +use codex_shell_command::is_safe_command::is_known_safe_command; +use codex_tools::ToolName; +use codex_utils_output_truncation::approx_token_count; + +use super::ExecCommandArgs; +use super::ExecCommandEnvironmentArgs; +use super::effective_max_output_tokens; +use super::get_command; +use super::post_unified_exec_tool_use_payload; + +pub struct ExecCommandHandler; + +impl ToolHandler for ExecCommandHandler { + type Output = ExecCommandToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("exec_command") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + tracing::error!( + "This should never happen, invocation payload is wrong: {:?}", + invocation.payload + ); + return true; + }; + + let Ok(params) = parse_arguments::(arguments) else { + return true; + }; + let command = match get_command( + ¶ms, + invocation.session.user_shell(), + &invocation.turn.tools_config.unified_exec_shell_mode, + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(command) => command, + Err(_) => return true, + }; + !is_known_safe_command(&command) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + let ToolPayload::Function { arguments } = &invocation.payload else { + return None; + }; + + parse_arguments::(arguments) + .ok() + .map(|args| PreToolUsePayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ "command": args.cmd }), + }) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + post_unified_exec_tool_use_payload(invocation, result) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "exec_command handler received unsupported payload".to_string(), + )); + } + }; + + let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager; + let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); + let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?; + let Some(turn_environment) = + resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())? + else { + return Err(FunctionCallError::RespondToModel( + "unified exec is unavailable in this session".to_string(), + )); + }; + let cwd = environment_args + .workdir + .as_deref() + .filter(|workdir| !workdir.is_empty()) + .map_or_else( + || turn_environment.cwd.clone(), + |workdir| turn_environment.cwd.join(workdir), + ); + let environment = Arc::clone(&turn_environment.environment); + let fs = environment.get_filesystem(); + let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; + let hook_command = args.cmd.clone(); + maybe_emit_implicit_skill_invocation( + session.as_ref(), + context.turn.as_ref(), + &hook_command, + &cwd, + ) + .await; + let process_id = manager.allocate_process_id().await; + let command = get_command( + &args, + session.user_shell(), + &turn.tools_config.unified_exec_shell_mode, + turn.tools_config.allow_login_shell, + ) + .map_err(FunctionCallError::RespondToModel)?; + let command_for_display = codex_shell_command::parse_command::shlex_join(&command); + + let ExecCommandArgs { + tty, + yield_time_ms, + max_output_tokens, + sandbox_permissions, + additional_permissions, + justification, + prefix_rule, + .. + } = args; + let max_output_tokens = + effective_max_output_tokens(max_output_tokens, turn.truncation_policy); + + let exec_permission_approvals_enabled = + session.features().enabled(Feature::ExecPermissionApprovals); + let requested_additional_permissions = additional_permissions.clone(); + let effective_additional_permissions = apply_granted_turn_permissions( + context.session.as_ref(), + cwd.as_path(), + sandbox_permissions, + additional_permissions, + ) + .await; + let additional_permissions_allowed = exec_permission_approvals_enabled + || (session.features().enabled(Feature::RequestPermissionsTool) + && effective_additional_permissions.permissions_preapproved); + + // Sticky turn permissions have already been approved, so they should + // continue through the normal exec approval flow for the command. + if effective_additional_permissions + .sandbox_permissions + .requests_sandbox_override() + && !effective_additional_permissions.permissions_preapproved + && !matches!( + context.turn.approval_policy.value(), + codex_protocol::protocol::AskForApproval::OnRequest + ) + { + let approval_policy = context.turn.approval_policy.value(); + manager.release_process_id(process_id).await; + return Err(FunctionCallError::RespondToModel(format!( + "approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}" + ))); + } + + let normalized_additional_permissions = match implicit_granted_permissions( + sandbox_permissions, + requested_additional_permissions.as_ref(), + &effective_additional_permissions, + ) + .map_or_else( + || { + normalize_and_validate_additional_permissions( + additional_permissions_allowed, + context.turn.approval_policy.value(), + effective_additional_permissions.sandbox_permissions, + effective_additional_permissions.additional_permissions, + effective_additional_permissions.permissions_preapproved, + &cwd, + ) + }, + |permissions| Ok(Some(permissions)), + ) { + Ok(normalized) => normalized, + Err(err) => { + manager.release_process_id(process_id).await; + return Err(FunctionCallError::RespondToModel(err)); + } + }; + + if let Some(output) = intercept_apply_patch( + &command, + &cwd, + fs.as_ref(), + context.session.clone(), + context.turn.clone(), + Some(&tracker), + &context.call_id, + "exec_command", + ) + .await? + { + manager.release_process_id(process_id).await; + return Ok(ExecCommandToolOutput { + event_call_id: String::new(), + chunk_id: String::new(), + wall_time: std::time::Duration::ZERO, + raw_output: output.into_text().into_bytes(), + max_output_tokens: Some(max_output_tokens), + process_id: None, + exit_code: None, + original_token_count: None, + hook_command: None, + }); + } + + emit_unified_exec_tty_metric(&turn.session_telemetry, tty); + match manager + .exec_command( + ExecCommandRequest { + command, + hook_command: hook_command.clone(), + process_id, + yield_time_ms, + max_output_tokens: Some(max_output_tokens), + cwd, + environment, + network: context.turn.network.clone(), + tty, + sandbox_permissions: effective_additional_permissions.sandbox_permissions, + additional_permissions: normalized_additional_permissions, + additional_permissions_preapproved: effective_additional_permissions + .permissions_preapproved, + justification, + prefix_rule, + }, + &context, + ) + .await + { + Ok(response) => Ok(response), + Err(UnifiedExecError::SandboxDenied { output, .. }) => { + let output_text = output.aggregated_output.text; + let original_token_count = approx_token_count(&output_text); + Ok(ExecCommandToolOutput { + event_call_id: context.call_id.clone(), + chunk_id: generate_chunk_id(), + wall_time: output.duration, + raw_output: output_text.into_bytes(), + max_output_tokens: Some(max_output_tokens), + // Sandbox denial is terminal, so there is no live + // process for write_stdin to resume. + process_id: None, + exit_code: Some(output.exit_code), + original_token_count: Some(original_token_count), + hook_command: Some(hook_command), + }) + } + Err(err) => Err(FunctionCallError::RespondToModel(format!( + "exec_command failed for `{command_for_display}`: {err:?}" + ))), + } + } +} + +fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { + session_telemetry.counter( + TOOL_CALL_UNIFIED_EXEC_METRIC, + /*inc*/ 1, + &[("tty", if tty { "true" } else { "false" })], + ); +} diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs new file mode 100644 index 0000000000..1e9c68f227 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -0,0 +1,104 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::ExecCommandToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::unified_exec::WriteStdinRequest; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::TerminalInteractionEvent; +use codex_tools::ToolName; +use serde::Deserialize; + +use super::effective_max_output_tokens; +use super::post_unified_exec_tool_use_payload; + +#[derive(Debug, Deserialize)] +struct WriteStdinArgs { + // The model is trained on `session_id`. + session_id: i32, + #[serde(default)] + chars: String, + #[serde(default = "super::default_write_stdin_yield_time_ms")] + yield_time_ms: u64, + #[serde(default)] + max_output_tokens: Option, +} + +pub struct WriteStdinHandler; + +impl ToolHandler for WriteStdinHandler { + type Output = ExecCommandToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("write_stdin") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + true + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + post_unified_exec_tool_use_payload(invocation, result) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "write_stdin handler received unsupported payload".to_string(), + )); + } + }; + + let args: WriteStdinArgs = parse_arguments(&arguments)?; + let max_output_tokens = + effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy); + let response = session + .services + .unified_exec_manager + .write_stdin(WriteStdinRequest { + process_id: args.session_id, + input: &args.chars, + yield_time_ms: args.yield_time_ms, + max_output_tokens: Some(max_output_tokens), + }) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) + })?; + + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: args.session_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + + Ok(response) + } +} 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 1bdd0b82f9..8818b2e344 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -1,17 +1,10 @@ use super::*; use crate::shell::default_user_shell; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; -use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; -use codex_protocol::models::FileSystemPermissions; use codex_tools::UnifiedExecShellMode; use codex_tools::ZshForkConfig; use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::PathExt; use pretty_assertions::assert_eq; -use std::fs; use std::sync::Arc; -use tempfile::tempdir; use crate::session::tests::make_session_and_context; use crate::tools::context::ExecCommandToolOutput; @@ -185,46 +178,13 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result< Ok(()) } -#[test] -fn exec_command_args_resolve_relative_additional_permissions_against_workdir() -> anyhow::Result<()> -{ - let cwd = tempdir()?; - let workdir = cwd.path().join("nested"); - fs::create_dir_all(&workdir)?; - let expected_write = workdir.join("relative-write.txt"); - let json = r#"{ - "cmd": "echo hello", - "workdir": "nested", - "additional_permissions": { - "file_system": { - "write": ["./relative-write.txt"] - } - } - }"#; - - let base_path = resolve_workdir_base_path(json, &cwd.path().abs())?; - let args: ExecCommandArgs = parse_arguments_with_base_path(json, &base_path)?; - - assert_eq!( - args.additional_permissions, - Some(PermissionProfile { - file_system: Some(FileSystemPermissions::from_read_write_roots( - /*read*/ None, - Some(vec![expected_write.abs()]), - )), - ..Default::default() - }) - ); - Ok(()) -} - #[tokio::test] async fn exec_command_pre_tool_use_payload_uses_raw_command() { let payload = ToolPayload::Function { arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(), }; let (session, turn) = make_session_and_context().await; - let handler = UnifiedExecHandler; + let handler = ExecCommandHandler; assert_eq!( handler.pre_tool_use_payload(&ToolInvocation { @@ -250,7 +210,7 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() { arguments: serde_json::json!({ "chars": "echo hi" }).to_string(), }; let (session, turn) = make_session_and_context().await; - let handler = UnifiedExecHandler; + let handler = WriteStdinHandler; assert_eq!( handler.pre_tool_use_payload(&ToolInvocation { @@ -284,8 +244,9 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-43", payload).await; + let handler = ExecCommandHandler; assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id: "call-43".to_string(), @@ -312,9 +273,10 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-44", payload).await; + let handler = ExecCommandHandler; assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id: "call-44".to_string(), @@ -341,10 +303,8 @@ async fn exec_command_post_tool_use_payload_skips_running_sessions() { hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-45", payload).await; - assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), - None - ); + let handler = ExecCommandHandler; + assert_eq!(handler.post_tool_use_payload(&invocation, &output), None); } #[tokio::test] @@ -368,9 +328,10 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman hook_command: Some("sleep 1; echo finished".to_string()), }; let invocation = invocation_for_payload("write_stdin", "write-stdin-call", payload).await; + let handler = WriteStdinHandler; assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id: "exec-call-45".to_string(), @@ -409,10 +370,11 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ }; 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; + let handler = WriteStdinHandler; let payloads = [ - UnifiedExecHandler.post_tool_use_payload(&invocation_b, &output_b), - UnifiedExecHandler.post_tool_use_payload(&invocation_a, &output_a), + handler.post_tool_use_payload(&invocation_b, &output_b), + handler.post_tool_use_payload(&invocation_a, &output_a), ]; assert_eq!( diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index d05807ef78..a7cbe7d975 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -19,6 +19,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; pub struct ViewImageHandler; @@ -39,6 +40,10 @@ enum ViewImageDetail { impl ToolHandler for ViewImageHandler { type Output = ViewImageOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("view_image") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index dcb42c36c6..c618d778d6 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -227,12 +227,13 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); + let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, permissions: &turn_ctx.permission_profile, enforce_managed_network: managed_network_active, manager: &self.sandbox, - sandbox_cwd: &turn_ctx.cwd, + sandbox_cwd, codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, @@ -350,7 +351,7 @@ impl ToolOrchestrator { permissions: &turn_ctx.permission_profile, enforce_managed_network: managed_network_active, manager: &self.sandbox, - sandbox_cwd: &turn_ctx.cwd, + sandbox_cwd, codex_linux_sandbox_exe: None, use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index e1027c9fa9..bdf18cf2fe 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -44,6 +44,9 @@ pub enum ToolKind { pub trait ToolHandler: Send + Sync { type Output: ToolOutput + 'static; + /// The concrete tool name handled by this handler instance. + fn tool_name(&self) -> ToolName; + fn kind(&self) -> ToolKind; fn matches_kind(&self, payload: &ToolPayload) -> bool { @@ -227,10 +230,11 @@ impl ToolRegistry { } #[cfg(test)] - pub(crate) fn with_handler_for_test(name: ToolName, handler: Arc) -> Self + pub(crate) fn with_handler_for_test(handler: Arc) -> Self where T: ToolHandler + 'static, { + let name = handler.tool_name(); Self::new(HashMap::from([(name, handler as Arc)])) } @@ -250,14 +254,6 @@ impl ToolRegistry { self.handler(name)?.create_diff_consumer() } - // TODO(jif) for dynamic tools. - // pub fn register(&mut self, name: impl Into, handler: Arc) { - // let name = name.into(); - // if self.handlers.insert(name.clone(), handler).is_some() { - // warn!("overwriting handler for tool {name}"); - // } - // } - #[expect( clippy::await_holding_invalid_type, reason = "tool dispatch must keep active-turn accounting atomic" @@ -458,7 +454,6 @@ impl ToolRegistry { outcome.additional_contexts.clone(), ) .await; - let replacement_text = if outcome.should_stop { Some( outcome @@ -540,11 +535,11 @@ impl ToolRegistryBuilder { .push(ConfiguredToolSpec::new(spec, supports_parallel_tool_calls)); } - pub fn register_handler(&mut self, name: impl Into, handler: Arc) + pub fn register_handler(&mut self, handler: Arc) where H: ToolHandler + 'static, { - let name = name.into(); + let name = handler.tool_name(); let display_name = name.display(); let handler: Arc = handler; if self.handlers.insert(name, handler).is_some() { @@ -552,24 +547,6 @@ impl ToolRegistryBuilder { } } - // TODO(jif) for dynamic tools. - // pub fn register_many(&mut self, names: I, handler: Arc) - // where - // I: IntoIterator, - // I::Item: Into, - // { - // for name in names { - // let name = name.into(); - // if self - // .handlers - // .insert(name.clone(), handler.clone()) - // .is_some() - // { - // warn!("overwriting handler for tool {name}"); - // } - // } - // } - pub fn build(self) -> (Vec, ToolRegistry) { let registry = ToolRegistry::new(self.handlers); (self.specs, registry) diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index d44c3d0f9b..ef7273999d 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,12 +1,17 @@ use super::*; use pretty_assertions::assert_eq; -#[derive(Default)] -struct TestHandler; +struct TestHandler { + tool_name: codex_tools::ToolName, +} impl ToolHandler for TestHandler { type Output = crate::tools::context::FunctionToolOutput; + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -21,12 +26,16 @@ impl ToolHandler for TestHandler { #[test] fn handler_looks_up_namespaced_aliases_explicitly() { - let plain_handler = Arc::new(TestHandler) as Arc; - let namespaced_handler = Arc::new(TestHandler) as Arc; let namespace = "mcp__codex_apps__gmail"; let tool_name = "gmail_get_recent_emails"; let plain_name = codex_tools::ToolName::plain(tool_name); let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name); + let plain_handler = Arc::new(TestHandler { + tool_name: plain_name.clone(), + }) as Arc; + let namespaced_handler = Arc::new(TestHandler { + tool_name: namespaced_name.clone(), + }) as Arc; let registry = ToolRegistry::new(HashMap::from([ (plain_name.clone(), Arc::clone(&plain_handler)), (namespaced_name.clone(), Arc::clone(&namespaced_handler)), diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 84e469e22d..7c2aa5e8e5 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -371,6 +371,29 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul .to_string(), ) .context("write hooks.json")?; + let config_toml_path = turn_context + .config + .codex_home + .join(codex_config::CONFIG_TOML_FILE); + let hook_list = codex_hooks::list_hooks(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + ..HooksConfig::default() + }); + assert_eq!(hook_list.hooks.len(), 1); + let trusted_config_layer_stack = turn_context.config.config_layer_stack.with_user_config( + &config_toml_path, + serde_json::from_value(serde_json::json!({ + "hooks": { + "state": { + hook_list.hooks[0].key.clone(): { + "trusted_hash": hook_list.hooks[0].current_hash.clone(), + }, + }, + }, + })) + .context("build trusted hook state")?, + ); let mut hook_shell_argv = session .user_shell() @@ -382,7 +405,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul .hooks .store(Arc::new(Hooks::new(HooksConfig { feature_enabled: true, - config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + config_layer_stack: Some(trusted_config_layer_stack), shell_program: Some(hook_shell_program), shell_args: hook_shell_argv, ..HooksConfig::default() diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 1bc5d72127..42f311bfcb 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -37,6 +37,7 @@ use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; +use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -48,6 +49,7 @@ use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; +use std::sync::Arc; use tokio_util::sync::CancellationToken; /// Request payload used by the unified-exec runtime after approvals and @@ -58,6 +60,7 @@ pub struct UnifiedExecRequest { pub hook_command: String, pub process_id: i32, pub cwd: AbsolutePathBuf, + pub environment: Arc, pub env: HashMap, pub exec_server_env_config: Option, pub explicit_env_overrides: HashMap, @@ -214,6 +217,10 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b AbsolutePathBuf> { + Some(&req.cwd) + } + fn network_approval_spec( &self, req: &UnifiedExecRequest, @@ -252,11 +259,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt if let Some(network) = managed_network { network.apply_to_env(&mut env); } - let environment_is_remote = ctx - .turn - .environments - .primary() - .is_some_and(|turn_environment| turn_environment.environment.is_remote()); + let environment_is_remote = req.environment.is_remote(); let command = if environment_is_remote { base_command.to_vec() } else { @@ -293,14 +296,10 @@ impl<'a> ToolRuntime for UnifiedExecRunt .await? { Some(prepared) => { - let Some(turn_environment) = ctx.turn.environments.primary() else { + if req.environment.is_remote() { return Err(ToolError::Rejected( - "exec_command is unavailable in this session".to_string(), - )); - }; - if turn_environment.environment.is_remote() { - return Err(ToolError::Rejected( - "unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(), + "unified_exec zsh-fork is not supported for remote environments" + .to_string(), )); } return self @@ -310,7 +309,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &prepared.exec_request, req.tty, prepared.spawn_lifecycle, - turn_environment.environment.as_ref(), + req.environment.as_ref(), ) .await .map_err(|err| match err { @@ -338,18 +337,13 @@ impl<'a> ToolRuntime for UnifiedExecRunt .env_for(command, options, managed_network) .map_err(|err| ToolError::Codex(err.into()))?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); - let Some(turn_environment) = ctx.turn.environments.primary() else { - return Err(ToolError::Rejected( - "exec_command is unavailable in this session".to_string(), - )); - }; self.manager .open_session_with_exec_env( req.process_id, &exec_env, req.tty, Box::new(NoopSpawnLifecycle), - turn_environment.environment.as_ref(), + req.environment.as_ref(), ) .await .map_err(|err| match err { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 122cd00fad..c17247beb4 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -358,6 +358,10 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { None } + fn sandbox_cwd<'a>(&self, _req: &'a Req) -> Option<&'a AbsolutePathBuf> { + None + } + async fn run( &mut self, req: &Req, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 53cb34f7a4..994e20ccd0 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,6 +1,7 @@ use crate::shell::Shell; use crate::shell::ShellType; -use crate::tools::handlers::agent_jobs::BatchJobHandler; +use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; +use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; @@ -76,12 +77,17 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; + use crate::tools::handlers::ContainerExecHandler; + use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; - use crate::tools::handlers::GoalHandler; - use crate::tools::handlers::ListDirHandler; + use crate::tools::handlers::ExecCommandHandler; + use crate::tools::handlers::GetGoalHandler; + use crate::tools::handlers::ListMcpResourceTemplatesHandler; + use crate::tools::handlers::ListMcpResourcesHandler; + use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::McpHandler; - use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; + use crate::tools::handlers::ReadMcpResourceHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; @@ -90,8 +96,9 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UnavailableToolHandler; - use crate::tools::handlers::UnifiedExecHandler; + use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; + use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::multi_agents::CloseAgentHandler; use crate::tools::handlers::multi_agents::ResumeAgentHandler; use crate::tools::handlers::multi_agents::SendInputHandler; @@ -151,30 +158,11 @@ pub(crate) fn build_specs_with_discoverable_tools( }, }, ); - let shell_handler = Arc::new(ShellHandler); - let unified_exec_handler = Arc::new(UnifiedExecHandler); - let plan_handler = Arc::new(PlanHandler); - let apply_patch_handler = Arc::new(ApplyPatchHandler); - let dynamic_tool_handler = Arc::new(DynamicToolHandler); - let goal_handler = Arc::new(GoalHandler); - let view_image_handler = Arc::new(ViewImageHandler); - let mcp_handler = Arc::new(McpHandler); - let mcp_resource_handler = Arc::new(McpResourceHandler); - let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend)); - let request_permissions_handler = Arc::new(RequestPermissionsHandler); - let request_user_input_handler = Arc::new(RequestUserInputHandler { - available_modes: config.request_user_input_available_modes.clone(), - }); let deferred_dynamic_tools = dynamic_tools .iter() .filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none())) .cloned() .collect::>(); - let mut tool_search_handler = None; - let request_plugin_install_handler = Arc::new(RequestPluginInstallHandler); - let code_mode_handler = Arc::new(CodeModeExecuteHandler); - let code_mode_wait_handler = Arc::new(CodeModeWaitHandler); - let unavailable_tool_handler = Arc::new(UnavailableToolHandler); let mut existing_spec_names = plan .specs .iter() @@ -192,116 +180,137 @@ pub(crate) fn build_specs_with_discoverable_tools( } for handler in plan.handlers { + let name = handler.name; match handler.kind { - ToolHandlerKind::AgentJobs => { - builder.register_handler(handler.name, Arc::new(BatchJobHandler)); - } ToolHandlerKind::ApplyPatch => { - builder.register_handler(handler.name, apply_patch_handler.clone()); + builder.register_handler(Arc::new(ApplyPatchHandler)); } ToolHandlerKind::CloseAgentV1 => { - builder.register_handler(handler.name, Arc::new(CloseAgentHandler)); + builder.register_handler(Arc::new(CloseAgentHandler)); } ToolHandlerKind::CloseAgentV2 => { - builder.register_handler(handler.name, Arc::new(CloseAgentHandlerV2)); + builder.register_handler(Arc::new(CloseAgentHandlerV2)); } ToolHandlerKind::CodeModeExecute => { - builder.register_handler(handler.name, code_mode_handler.clone()); + builder.register_handler(Arc::new(CodeModeExecuteHandler)); } ToolHandlerKind::CodeModeWait => { - builder.register_handler(handler.name, code_mode_wait_handler.clone()); + builder.register_handler(Arc::new(CodeModeWaitHandler)); + } + ToolHandlerKind::ContainerExec => { + builder.register_handler(Arc::new(ContainerExecHandler)); + } + ToolHandlerKind::CreateGoal => { + builder.register_handler(Arc::new(CreateGoalHandler)); } ToolHandlerKind::DynamicTool => { - builder.register_handler(handler.name, dynamic_tool_handler.clone()); + builder.register_handler(Arc::new(DynamicToolHandler::new(name))); + } + ToolHandlerKind::ExecCommand => { + builder.register_handler(Arc::new(ExecCommandHandler)); } ToolHandlerKind::FollowupTaskV2 => { - builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2)); + builder.register_handler(Arc::new(FollowupTaskHandlerV2)); } - ToolHandlerKind::Goal => { - builder.register_handler(handler.name, goal_handler.clone()); + ToolHandlerKind::GetGoal => { + builder.register_handler(Arc::new(GetGoalHandler)); } ToolHandlerKind::ListAgentsV2 => { - builder.register_handler(handler.name, Arc::new(ListAgentsHandlerV2)); + builder.register_handler(Arc::new(ListAgentsHandlerV2)); } - ToolHandlerKind::ListDir => { - builder.register_handler(handler.name, Arc::new(ListDirHandler)); + ToolHandlerKind::ListMcpResources => { + builder.register_handler(Arc::new(ListMcpResourcesHandler)); + } + ToolHandlerKind::ListMcpResourceTemplates => { + builder.register_handler(Arc::new(ListMcpResourceTemplatesHandler)); + } + ToolHandlerKind::LocalShell => { + builder.register_handler(Arc::new(LocalShellHandler)); } ToolHandlerKind::Mcp => { - builder.register_handler(handler.name, mcp_handler.clone()); - } - ToolHandlerKind::McpResource => { - builder.register_handler(handler.name, mcp_resource_handler.clone()); + builder.register_handler(Arc::new(McpHandler::new(name))); } ToolHandlerKind::Plan => { - builder.register_handler(handler.name, plan_handler.clone()); + builder.register_handler(Arc::new(PlanHandler)); + } + ToolHandlerKind::ReadMcpResource => { + builder.register_handler(Arc::new(ReadMcpResourceHandler)); + } + ToolHandlerKind::ReportAgentJobResult => { + builder.register_handler(Arc::new(ReportAgentJobResultHandler)); } ToolHandlerKind::RequestPermissions => { - builder.register_handler(handler.name, request_permissions_handler.clone()); + builder.register_handler(Arc::new(RequestPermissionsHandler)); } ToolHandlerKind::RequestUserInput => { - builder.register_handler(handler.name, request_user_input_handler.clone()); + builder.register_handler(Arc::new(RequestUserInputHandler { + available_modes: config.request_user_input_available_modes.clone(), + })); } ToolHandlerKind::ResumeAgentV1 => { - builder.register_handler(handler.name, Arc::new(ResumeAgentHandler)); + builder.register_handler(Arc::new(ResumeAgentHandler)); } ToolHandlerKind::SendInputV1 => { - builder.register_handler(handler.name, Arc::new(SendInputHandler)); + builder.register_handler(Arc::new(SendInputHandler)); } ToolHandlerKind::SendMessageV2 => { - builder.register_handler(handler.name, Arc::new(SendMessageHandlerV2)); + builder.register_handler(Arc::new(SendMessageHandlerV2)); } ToolHandlerKind::Shell => { - builder.register_handler(handler.name, shell_handler.clone()); + builder.register_handler(Arc::new(ShellHandler)); } ToolHandlerKind::ShellCommand => { - builder.register_handler(handler.name, shell_command_handler.clone()); + builder.register_handler(Arc::new(ShellCommandHandler::from( + config.shell_command_backend, + ))); + } + ToolHandlerKind::SpawnAgentsOnCsv => { + builder.register_handler(Arc::new(SpawnAgentsOnCsvHandler)); } ToolHandlerKind::SpawnAgentV1 => { - builder.register_handler(handler.name, Arc::new(SpawnAgentHandler)); + builder.register_handler(Arc::new(SpawnAgentHandler)); } ToolHandlerKind::SpawnAgentV2 => { - builder.register_handler(handler.name, Arc::new(SpawnAgentHandlerV2)); + builder.register_handler(Arc::new(SpawnAgentHandlerV2)); } ToolHandlerKind::TestSync => { - builder.register_handler(handler.name, Arc::new(TestSyncHandler)); + builder.register_handler(Arc::new(TestSyncHandler)); } ToolHandlerKind::ToolSearch => { - if tool_search_handler.is_none() { - let entries = build_tool_search_entries_for_config( - config, - deferred_mcp_tools.as_ref(), - &deferred_dynamic_tools, - ); - tool_search_handler = Some(Arc::new(ToolSearchHandler::new(entries))); - } - if let Some(tool_search_handler) = tool_search_handler.as_ref() { - builder.register_handler(handler.name, tool_search_handler.clone()); - } + let entries = build_tool_search_entries_for_config( + config, + deferred_mcp_tools.as_ref(), + &deferred_dynamic_tools, + ); + builder.register_handler(Arc::new(ToolSearchHandler::new(entries))); } ToolHandlerKind::RequestPluginInstall => { - builder.register_handler(handler.name, request_plugin_install_handler.clone()); + builder.register_handler(Arc::new(RequestPluginInstallHandler)); } - ToolHandlerKind::UnifiedExec => { - builder.register_handler(handler.name, unified_exec_handler.clone()); + ToolHandlerKind::UpdateGoal => { + builder.register_handler(Arc::new(UpdateGoalHandler)); } ToolHandlerKind::ViewImage => { - builder.register_handler(handler.name, view_image_handler.clone()); + builder.register_handler(Arc::new(ViewImageHandler)); } ToolHandlerKind::WaitAgentV1 => { - builder.register_handler(handler.name, Arc::new(WaitAgentHandler)); + builder.register_handler(Arc::new(WaitAgentHandler)); } ToolHandlerKind::WaitAgentV2 => { - builder.register_handler(handler.name, Arc::new(WaitAgentHandlerV2)); + builder.register_handler(Arc::new(WaitAgentHandlerV2)); + } + ToolHandlerKind::WriteStdin => { + builder.register_handler(Arc::new(WriteStdinHandler)); } } } if let Some(deferred_mcp_tools) = deferred_mcp_tools.as_ref() { - for (name, _) in deferred_mcp_tools.iter().filter(|(name, _)| { + for (_, tool) in deferred_mcp_tools.iter().filter(|(name, _)| { !mcp_tools .as_ref() .is_some_and(|tools| tools.contains_key(*name)) }) { - builder.register_handler(name.clone(), mcp_handler.clone()); + builder.register_handler(Arc::new(McpHandler::new(tool.canonical_tool_name()))); } } @@ -330,7 +339,7 @@ pub(crate) fn build_specs_with_discoverable_tools( }; builder.push_spec(spec); } - builder.register_handler(unavailable_tool, unavailable_tool_handler.clone()); + builder.register_handler(Arc::new(UnavailableToolHandler::new(unavailable_tool))); } builder } diff --git a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs index 5f11816553..99d90c3451 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs @@ -26,12 +26,17 @@ use crate::tools::registry::ToolKind; use crate::tools::registry::ToolRegistry; use crate::turn_diff_tracker::TurnDiffTracker; -#[derive(Default)] -struct TestHandler; +struct TestHandler { + tool_name: codex_tools::ToolName, +} impl ToolHandler for TestHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -53,10 +58,9 @@ async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> a "await tools.test_tool({})", ); - let registry = ToolRegistry::with_handler_for_test( - codex_tools::ToolName::plain("test_tool"), - Arc::new(TestHandler), - ); + let registry = ToolRegistry::with_handler_for_test(Arc::new(TestHandler { + tool_name: codex_tools::ToolName::plain("test_tool"), + })); let session = Arc::new(session); let turn = Arc::new(turn); @@ -165,10 +169,9 @@ async fn dispatch_lifecycle_trace_records_incompatible_payload_failures() -> any let (mut session, turn) = make_session_and_context().await; attach_test_trace(&mut session, &turn, temp.path())?; - let registry = ToolRegistry::with_handler_for_test( - codex_tools::ToolName::plain("test_tool"), - Arc::new(TestHandler), - ); + let registry = ToolRegistry::with_handler_for_test(Arc::new(TestHandler { + tool_name: codex_tools::ToolName::plain("test_tool"), + })); let session = Arc::new(session); let turn = Arc::new(turn); @@ -200,10 +203,7 @@ async fn missing_code_mode_wait_traces_only_the_wait_tool_call() -> anyhow::Resu let (mut session, turn) = make_session_and_context().await; attach_test_trace(&mut session, &turn, temp.path())?; - let registry = ToolRegistry::with_handler_for_test( - codex_tools::ToolName::plain(WAIT_TOOL_NAME), - Arc::new(CodeModeWaitHandler), - ); + let registry = ToolRegistry::with_handler_for_test(Arc::new(CodeModeWaitHandler)); let session = Arc::new(session); let turn = Arc::new(turn); diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index f6a338b9ac..02760582f2 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -16,11 +16,19 @@ use codex_git_utils::get_has_changes; use codex_git_utils::get_head_commit_hash; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::SessionSource; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; +const MODEL_KEY: &str = "model"; +const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; +pub(crate) struct McpTurnMetadataContext<'a> { + pub(crate) model: &'a str, + pub(crate) reasoning_effort: Option, +} + #[derive(Clone, Debug, Default)] struct WorkspaceGitMetadata { associated_remote_urls: Option>, @@ -61,7 +69,9 @@ pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - thread_source: Option<&'static str>, + thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + thread_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -107,7 +117,8 @@ fn merge_turn_metadata( fn build_turn_metadata_bag( session_id: Option, - thread_source: Option<&'static str>, + thread_id: Option, + thread_source: Option, turn_id: Option, sandbox: Option, repo_root: Option, @@ -122,6 +133,7 @@ fn build_turn_metadata_bag( TurnMetadataBag { session_id, + thread_id, thread_source, turn_id, workspaces, @@ -151,6 +163,7 @@ pub async fn build_turn_metadata_header( build_turn_metadata_bag( /*session_id*/ None, + /*thread_id*/ None, /*thread_source*/ None, /*turn_id*/ None, sandbox.map(ToString::to_string), @@ -177,9 +190,11 @@ pub(crate) struct TurnMetadataState { } impl TurnMetadataState { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( session_id: String, - session_source: &SessionSource, + thread_id: String, + thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, permission_profile: &PermissionProfile, @@ -197,7 +212,8 @@ impl TurnMetadataState { ); let base_metadata = build_turn_metadata_bag( Some(session_id), - session_source.thread_source_name(), + Some(thread_id), + thread_source, Some(turn_id), sandbox, /*repo_root*/ None, @@ -248,9 +264,28 @@ impl TurnMetadataState { .or(Some(header)) } - pub(crate) fn current_meta_value(&self) -> Option { - self.current_header_value() - .and_then(|header| serde_json::from_str(&header).ok()) + pub(crate) fn current_meta_value_for_mcp_request( + &self, + context: McpTurnMetadataContext<'_>, + ) -> Option { + let header = self.current_header_value()?; + let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.insert( + MODEL_KEY.to_string(), + Value::String(context.model.to_string()), + ); + match context.reasoning_effort { + Some(reasoning_effort) => { + metadata.insert( + REASONING_EFFORT_KEY.to_string(), + Value::String(reasoning_effort.to_string()), + ); + } + None => { + metadata.remove(REASONING_EFFORT_KEY); + } + } + Some(Value::Object(metadata)) } pub(crate) fn set_responsesapi_client_metadata( @@ -293,6 +328,7 @@ impl TurnMetadataState { let enriched_metadata = build_turn_metadata_bag( state.base_metadata.session_id.clone(), + state.base_metadata.thread_id.clone(), state.base_metadata.thread_source, state.base_metadata.turn_id.clone(), state.base_metadata.sandbox.clone(), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 6504eadd67..2a38447f86 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -2,9 +2,9 @@ use super::*; use crate::sandbox_tags::sandbox_tag; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use core_test_support::PathBufExt; use core_test_support::PathExt; use pretty_assertions::assert_eq; @@ -13,6 +13,13 @@ use std::collections::HashMap; use tempfile::TempDir; use tokio::process::Command; +fn test_mcp_turn_metadata_context() -> McpTurnMetadataContext<'static> { + McpTurnMetadataContext { + model: "gpt-5.4", + reasoning_effort: Some(ReasoningEffortConfig::High), + } +} + #[tokio::test] async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { let temp_dir = TempDir::new().expect("temp dir"); @@ -87,7 +94,8 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + "thread-a".to_string(), + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, @@ -99,25 +107,26 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let json: Value = serde_json::from_str(&header).expect("json"); let sandbox_name = json.get("sandbox").and_then(Value::as_str); let session_id = json.get("session_id").and_then(Value::as_str); + let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); assert_eq!(sandbox_name, Some(expected_sandbox)); assert_eq!(session_id, Some("session-a")); + assert_eq!(thread_id, Some("thread-a")); assert_eq!(thread_source, Some("user")); assert!(json.get("session_source").is_none()); } #[test] -fn turn_metadata_state_classifies_subagent_thread_source() { +fn turn_metadata_state_uses_explicit_subagent_thread_source() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); - let session_source = SessionSource::SubAgent(SubAgentSource::Review); - let state = TurnMetadataState::new( "session-a".to_string(), - &session_source, + "thread-a".to_string(), + Some(ThreadSource::Subagent), "turn-a".to_string(), cwd, &permission_profile, @@ -140,7 +149,8 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + "thread-a".to_string(), + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, @@ -158,6 +168,51 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { ); } +#[test] +fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + /*thread_source*/ None, + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let header_json: Value = serde_json::from_str(&header).expect("json"); + assert!(header_json.get("model").is_none()); + assert!(header_json.get("reasoning_effort").is_none()); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); + assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); + + let meta_without_reasoning_effort = state + .current_meta_value_for_mcp_request(McpTurnMetadataContext { + model: "gpt-5.4", + reasoning_effort: None, + }) + .expect("turn metadata should be present"); + assert_eq!( + meta_without_reasoning_effort["model"].as_str(), + Some("gpt-5.4") + ); + assert!( + meta_without_reasoning_effort + .get("reasoning_effort") + .is_none() + ); +} + #[test] fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let temp_dir = TempDir::new().expect("temp dir"); @@ -166,7 +221,8 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + "thread-a".to_string(), + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, @@ -192,7 +248,8 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + "thread-a".to_string(), + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, @@ -202,7 +259,13 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( state.set_responsesapi_client_metadata(HashMap::from([ ("fiber_run_id".to_string(), "fiber-123".to_string()), ("origin".to_string(), "東京".to_string()), + ("model".to_string(), "client-supplied".to_string()), + ( + "reasoning_effort".to_string(), + "client-supplied".to_string(), + ), ("session_id".to_string(), "client-supplied".to_string()), + ("thread_id".to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), ( "turn_started_at_unix_ms".to_string(), @@ -218,11 +281,20 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); assert_eq!(json["origin"].as_str(), Some("東京")); + assert_eq!(json["model"].as_str(), Some("client-supplied")); + assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); + assert_eq!(json["thread_id"].as_str(), Some("thread-a")); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert_eq!( json["turn_started_at_unix_ms"].as_i64(), Some(1_700_000_000_123) ); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); + assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); } diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 97b37e8d80..9b74baf64c 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -27,6 +27,7 @@ use std::collections::HashSet; use std::sync::Arc; use std::sync::Weak; +use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -93,7 +94,8 @@ pub(crate) struct ExecCommandRequest { pub process_id: i32, pub yield_time_ms: u64, pub max_output_tokens: Option, - pub workdir: Option, + pub cwd: AbsolutePathBuf, + pub environment: Arc, pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index c67abc48d6..4f85b1ddf7 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -371,10 +371,7 @@ impl UnifiedExecProcessManager { request: ExecCommandRequest, context: &UnifiedExecContext, ) -> Result { - let cwd = request - .workdir - .clone() - .unwrap_or_else(|| context.turn.cwd.clone()); + let cwd = request.cwd.clone(); let process = self .open_session_with_sandbox(&request, cwd.clone(), context) .await; @@ -1012,7 +1009,7 @@ impl UnifiedExecProcessManager { approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), file_system_sandbox_policy: &file_system_sandbox_policy, - sandbox_cwd: context.turn.cwd.as_path(), + sandbox_cwd: cwd.as_path(), sandbox_permissions: if request.additional_permissions_preapproved { crate::sandboxing::SandboxPermissions::UseDefault } else { @@ -1026,6 +1023,7 @@ impl UnifiedExecProcessManager { hook_command: request.hook_command.clone(), process_id: request.process_id, cwd, + environment: Arc::clone(&request.environment), env, exec_server_env_config: Some(exec_server_env_config), explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(), 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 0c5b714161..bde32c9ab4 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -175,7 +175,11 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { process_id: 123, yield_time_ms: 1000, max_output_tokens: None, - workdir: None, + cwd: turn.cwd.clone(), + environment: turn + .environments + .primary_environment() + .expect("primary environment"), network: None, tty: true, sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, diff --git a/codex-rs/core/tests/common/BUILD.bazel b/codex-rs/core/tests/common/BUILD.bazel index aec0c17817..983a2012b0 100644 --- a/codex-rs/core/tests/common/BUILD.bazel +++ b/codex-rs/core/tests/common/BUILD.bazel @@ -7,4 +7,7 @@ codex_rust_crate( lib_data_extra = [ "//codex-rs/core:model_availability_nux_fixtures", ], + deps_extra = [ + "@crates//:similar", + ], ) diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index f710aa36cc..8c1a79f02d 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -19,6 +19,7 @@ codex-config = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } +codex-hooks = { workspace = true } codex-login = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } @@ -32,6 +33,7 @@ opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } +similar = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["net", "time"] } tokio-tungstenite = { workspace = true } diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index cb899969d9..8aaefbbf15 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -1,5 +1,7 @@ use regex_lite::Regex; use serde_json::Value; +use similar::ChangeTag; +use similar::TextDiff; use std::sync::OnceLock; use crate::responses::ResponsesRequest; @@ -242,6 +244,102 @@ pub fn format_labeled_items_snapshot( format!("Scenario: {scenario}\n\n{sections}") } +/// Render changed JSON lines between two captured `/responses` request bodies. +/// +/// Request-parity tests use this to compare the entire JSON payload while showing only fields that +/// changed, with the same redactions as the other context snapshots. +pub fn format_request_body_diff_snapshot( + scenario: &str, + before_title: &str, + before_request: &ResponsesRequest, + after_title: &str, + after_request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let before = format_request_body_snapshot(before_request, options); + let after = format_request_body_snapshot(after_request, options); + let diff = format_changed_lines_diff(before_title, &before, after_title, &after); + format!("Scenario: {scenario}\n\n{diff}") +} + +fn format_request_body_snapshot( + request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let mut body = request.body_json(); + canonicalize_json_snapshot_value(&mut body, options); + serde_json::to_string_pretty(&body).expect("request body should serialize") +} + +fn canonicalize_json_snapshot_value(value: &mut Value, options: &ContextSnapshotOptions) { + match value { + Value::Array(values) => { + for value in values { + canonicalize_json_snapshot_value(value, options); + } + } + Value::Object(map) => { + // Keep request-body snapshots stable when serde_json preserves insertion order. + let mut entries = std::mem::take(map).into_iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + for (key, mut value) in entries { + canonicalize_json_snapshot_value(&mut value, options); + map.insert(key, value); + } + } + Value::String(text) => { + *text = format_snapshot_json_string(text, options); + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn format_snapshot_json_string(text: &str, options: &ContextSnapshotOptions) -> String { + let normalized = match options.render_mode { + ContextSnapshotRenderMode::RedactedText + | ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalize_snapshot_uuids( + &normalize_snapshot_line_endings(&canonicalize_snapshot_text(text)), + ), + ContextSnapshotRenderMode::FullText => normalize_snapshot_line_endings(text), + ContextSnapshotRenderMode::KindOnly => unreachable!(), + }; + match options.render_mode { + ContextSnapshotRenderMode::KindWithTextPrefix { max_chars } + if normalized.chars().count() > max_chars => + { + let prefix = normalized.chars().take(max_chars).collect::(); + format!("{prefix}...") + } + ContextSnapshotRenderMode::RedactedText + | ContextSnapshotRenderMode::FullText + | ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalized, + ContextSnapshotRenderMode::KindOnly => unreachable!(), + } +} + +fn format_changed_lines_diff( + before_title: &str, + before: &str, + after_title: &str, + after: &str, +) -> String { + let mut diff = format!("--- {before_title}\n+++ {after_title}\n"); + for change in TextDiff::from_lines(before, after).iter_all_changes() { + match change.tag() { + ChangeTag::Equal => {} + ChangeTag::Delete => { + diff.push('-'); + diff.push_str(change.value()); + } + ChangeTag::Insert => { + diff.push('+'); + diff.push_str(change.value()); + } + } + } + diff +} + fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String { match options.render_mode { ContextSnapshotRenderMode::RedactedText => { @@ -342,6 +440,17 @@ fn normalize_dynamic_snapshot_paths(text: &str) -> String { .into_owned() } +fn normalize_snapshot_uuids(text: &str) -> String { + static UUID_RE: OnceLock = OnceLock::new(); + let uuid_re = UUID_RE.get_or_init(|| { + Regex::new( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", + ) + .expect("uuid regex should compile") + }); + uuid_re.replace_all(text, "").into_owned() +} + #[cfg(test)] mod tests { use super::ContextSnapshotOptions; diff --git a/codex-rs/core/tests/common/hooks.rs b/codex-rs/core/tests/common/hooks.rs new file mode 100644 index 0000000000..239041a22c --- /dev/null +++ b/codex-rs/core/tests/common/hooks.rs @@ -0,0 +1,70 @@ +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerStack; +use codex_config::TomlValue; +use codex_core::config::Config; +use codex_features::Feature; +use codex_hooks::HookListEntry; +use codex_utils_absolute_path::AbsolutePathBuf; + +pub fn trust_discovered_hooks(config: &mut Config) { + if let Err(err) = config.features.enable(Feature::CodexHooks) { + panic!("test config should allow feature update: {err}"); + } + + let listed = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: true, + config_layer_stack: Some(config.config_layer_stack.clone()), + ..codex_hooks::HooksConfig::default() + }); + assert!( + !listed.hooks.is_empty(), + "trusted hook fixture should discover at least one hook" + ); + trust_hooks(config, listed.hooks); +} + +pub fn trust_hooks(config: &mut Config, hooks: Vec) { + config.config_layer_stack = + trusted_config_layer_stack(&config.config_layer_stack, &config.codex_home, hooks); +} + +pub fn trusted_config_layer_stack( + config_layer_stack: &ConfigLayerStack, + codex_home: &AbsolutePathBuf, + hooks: Vec, +) -> ConfigLayerStack { + let mut user_config = config_layer_stack + .get_user_layer() + .map(|layer| layer.config.clone()) + .unwrap_or_else(|| TomlValue::Table(Default::default())); + let Some(user_table) = user_config.as_table_mut() else { + panic!("user config should be a table"); + }; + let Some(hooks_table) = user_table + .entry("hooks") + .or_insert_with(|| TomlValue::Table(Default::default())) + .as_table_mut() + else { + panic!("hooks config should be a table"); + }; + let Some(state_table) = hooks_table + .entry("state") + .or_insert_with(|| TomlValue::Table(Default::default())) + .as_table_mut() + else { + panic!("hook state config should be a table"); + }; + for hook in hooks { + let mut hook_state = TomlValue::Table(Default::default()); + let Some(hook_state_table) = hook_state.as_table_mut() else { + panic!("hook state should be a table"); + }; + hook_state_table.insert( + "trusted_hash".to_string(), + TomlValue::String(hook.current_hash), + ); + state_table.insert(hook.key, hook_state); + } + + config_layer_stack.with_user_config(&codex_home.join(CONFIG_TOML_FILE), user_config) +} diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index d2ef9ddc60..70e1a3f0e4 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -24,6 +24,7 @@ use std::path::PathBuf; pub mod apps_test_server; pub mod context_snapshot; +pub mod hooks; pub mod process; pub mod responses; pub mod streaming_sse; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 14f652f58b..1728f5bc5c 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -15,7 +15,10 @@ use anyhow::anyhow; use codex_config::CloudRequirementsLoader; use codex_core::CodexThread; use codex_core::ThreadManager; +use codex_core::agent_graph_store_from_state_db; use codex_core::config::Config; +use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_core::shell::Shell; use codex_core::shell::get_shell_by_model_provided_path; use codex_core::thread_store_from_config; @@ -423,25 +426,32 @@ impl TestCodexBuilder { environment_manager: Arc, ) -> anyhow::Result { let auth = self.auth.clone(); - let state_db = codex_core::init_state_db(&config).await; let thread_manager = if config.model_catalog.is_some() { + let state_db = init_state_db_from_config(&config) + .await + .expect("test codex requires state db"); + let thread_store = thread_store_from_config(&config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home).await?; ThreadManager::new( &config, codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, Arc::clone(&environment_manager), /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), + state_db, + thread_store, + agent_graph_store, + installation_id, ) } else { - codex_core::test_support::thread_manager_with_models_provider_home_and_state( + codex_core::test_support::thread_manager_with_models_provider_and_home( auth.clone(), config.model_provider.clone(), config.codex_home.to_path_buf(), Arc::clone(&environment_manager), - state_db.clone(), ) + .await }; let thread_manager = Arc::new(thread_manager); let user_shell_override = self.user_shell_override.clone(); @@ -644,7 +654,7 @@ impl TestCodex { prompt, AskForApproval::Never, PermissionProfile::Disabled, - Some(service_tier), + Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), /*environments*/ None, ) .await @@ -706,7 +716,7 @@ impl TestCodex { prompt: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, - service_tier: Option>, + service_tier: Option>, environments: Option>, ) -> Result<()> { self.submit_turn_with_context( @@ -724,7 +734,7 @@ impl TestCodex { prompt: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, - service_tier: Option>, + service_tier: Option>, environments: Option>, ) -> Result<()> { let (sandbox_policy, permission_profile) = diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 56e9893116..af99790a1f 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -80,13 +80,13 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model = Some(model.clone()); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = TelemetryAuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Review); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -100,7 +100,8 @@ async fn responses_stream_includes_subagent_header_on_review() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, @@ -141,7 +142,7 @@ async fn responses_stream_includes_subagent_header_on_review() { } let request = request_recorder.single_request(); - let expected_window_id = format!("{conversation_id}:0"); + let expected_window_id = format!("{thread_id}:0"); assert_eq!( request.header("x-openai-subagent").as_deref(), Some("review") @@ -205,14 +206,14 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model = Some(model.clone()); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = TelemetryAuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Other("my-task".to_string())); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -226,7 +227,8 @@ async fn responses_stream_includes_subagent_header_on_other() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, @@ -317,7 +319,7 @@ async fn responses_respects_model_info_overrides_from_config() { let model = config.model.clone().expect("model configured"); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")) .auth_mode() @@ -327,7 +329,7 @@ async fn responses_respects_model_info_overrides_from_config() { let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -341,7 +343,8 @@ async fn responses_respects_model_info_overrides_from_config() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, @@ -452,7 +455,7 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() initial_parsed .get("thread_source") .and_then(serde_json::Value::as_str), - Some("user") + None ); let git_config_global = cwd.join("empty-git-config"); @@ -565,13 +568,13 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() first_parsed .get("thread_source") .and_then(serde_json::Value::as_str), - Some("user") + None ); assert_eq!( second_parsed .get("thread_source") .and_then(serde_json::Value::as_str), - Some("user") + None ); assert_eq!( first_turn_id, second_turn_id, diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 305346afac..6e985eebe0 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -313,7 +313,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu .features .enable(Feature::ResponsesWebsocketsV2) .expect("test config should allow feature update"); - config.service_tier = Some(ServiceTier::Fast); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); }); let test = builder.build_with_websocket_server(&server).await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 96cc1f3a99..2538c850e3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -807,7 +807,7 @@ async fn wait_for_spawned_thread(test: &TestCodex) -> Result> { let ids = test.thread_manager.list_thread_ids().await; if let Some(thread_id) = ids .iter() - .find(|id| **id != test.session_configured.session_id) + .find(|id| **id != test.session_configured.thread_id) { return test .thread_manager diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index afa586a158..143681e2e1 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -5,6 +5,9 @@ use codex_core::NewThread; use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ThreadManager; +use codex_core::agent_graph_store_from_state_db; +use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_core::thread_store_from_config; use codex_features::Feature; use codex_login::AuthManager; @@ -724,7 +727,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn includes_conversation_id_and_model_headers_in_request() { +async fn includes_session_id_thread_id_and_model_headers_in_request() { skip_if_no_network!(); // Mock server @@ -742,7 +745,8 @@ async fn includes_conversation_id_and_model_headers_in_request() { .await .expect("create new conversation"); let codex = test.codex.clone(); - let session_id = test.session_configured.session_id; + let expected_session_id = test.session_configured.session_id; + let expected_thread_id = test.session_configured.thread_id; codex .submit(Op::UserInput { @@ -762,6 +766,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { let request = resp_mock.single_request(); assert_eq!(request.path(), "/v1/responses"); let request_session_id = request.header("session_id").expect("session_id header"); + let request_thread_id = request.header("thread_id").expect("thread_id header"); let request_authorization = request .header("authorization") .expect("authorization header"); @@ -770,14 +775,15 @@ async fn includes_conversation_id_and_model_headers_in_request() { let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); - let session_id_string = session_id.to_string(); + let thread_id_string = expected_thread_id.to_string(); - assert_eq!(request_session_id, session_id_string); + assert_eq!(request_session_id, expected_session_id.to_string()); + 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_body["prompt_cache_key"].as_str(), - Some(session_id_string.as_str()) + Some(thread_id_string.as_str()) ); assert_eq!( request_body["client_metadata"]["x-codex-installation-id"].as_str(), @@ -870,9 +876,9 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -887,7 +893,8 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key( "unused-api-key", ))), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, SessionSource::Exec, @@ -1003,7 +1010,8 @@ async fn chatgpt_auth_sends_correct_request() { .await .expect("create new conversation"); let codex = test.codex.clone(); - let thread_id = test.session_configured.session_id; + let expected_session_id = test.session_configured.session_id; + let expected_thread_id = test.session_configured.thread_id; codex .submit(Op::UserInput { @@ -1031,11 +1039,13 @@ async fn chatgpt_auth_sends_correct_request() { .expect("chatgpt-account-id header"); let request_body = request.body_json(); - let session_id = request.header("session_id").expect("session_id header"); + let request_session_id = request.header("session_id").expect("session_id header"); + let request_thread_id = request.header("thread_id").expect("thread_id header"); let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); - assert_eq!(session_id, thread_id.to_string()); + assert_eq!(request_session_id, expected_session_id.to_string()); + assert_eq!(request_thread_id, expected_thread_id.to_string()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Access Token"); @@ -1106,14 +1116,24 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Ok(None) => panic!("No CodexAuth found in codex_home"), Err(e) => panic!("Failed to load CodexAuth: {e}"), }; + let state_db = init_state_db_from_config(&config) + .await + .expect("client test requires state db"); + let thread_store = thread_store_from_config(&config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home) + .await + .expect("resolve installation id"); let thread_manager = ThreadManager::new( &config, auth_manager, SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config, /*state_db*/ None), - /*state_db*/ None, + state_db, + thread_store, + agent_graph_store, + installation_id, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config.clone()) @@ -2274,11 +2294,11 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -2292,7 +2312,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider.clone(), SessionSource::Exec, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index cdbb65aabd..796b85ca5e 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -15,6 +15,7 @@ use codex_otel::MetricsConfig; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningSummary; @@ -87,7 +88,8 @@ fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTr struct WebsocketTestHarness { _codex_home: TempDir, client: ModelClient, - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, model_info: ModelInfo, effort: Option, summary: ReasoningSummary, @@ -125,7 +127,15 @@ async fn responses_websocket_streams_request() { ); assert_eq!( handshake.header(X_CLIENT_REQUEST_ID_HEADER), - Some(harness.conversation_id.to_string()) + Some(harness.thread_id.to_string()) + ); + assert_eq!( + handshake.header("session_id"), + Some(harness.session_id.to_string()) + ); + assert_eq!( + handshake.header("thread_id"), + Some(harness.thread_id.to_string()) ); assert_eq!( handshake.header(USER_AGENT_HEADER), @@ -161,6 +171,93 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_sends_response_processed_when_feature_enabled() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hi"), + ev_completed("resp-1"), + ], + vec![], + ]]) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::ResponsesWebsocketResponseProcessed) + .expect("test config should allow feature update"); + }); + let test = builder + .build_with_websocket_server(&server) + .await + .expect("build websocket codex"); + + test.submit_turn("hello") + .await + .expect("submission should send response.processed after processing"); + + let processed = server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 2) + .await; + assert_eq!( + processed.body_json(), + json!({ + "type": "response.processed", + "response_id": "resp-1", + }) + ); + + let connection = server.single_connection(); + assert_eq!(connection.len(), 3); + assert_eq!( + connection[1].body_json()["type"].as_str(), + Some("response.create") + ); + + server.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_omits_response_processed_without_feature() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hi"), + ev_completed("resp-1"), + ], + vec![], + ]]) + .await; + let mut builder = test_codex(); + let test = builder + .build_with_websocket_server(&server) + .await + .expect("build websocket codex"); + + test.submit_turn("hello") + .await + .expect("submission should complete without response.processed"); + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); @@ -1827,7 +1924,8 @@ async fn websocket_harness_with_provider_options( } let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); + let session_id = SessionId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); let exporter = InMemoryMetricExporter::default(); @@ -1837,7 +1935,7 @@ async fn websocket_harness_with_provider_options( ) .expect("in-memory metrics client"); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, MODEL, model_info.slug.as_str(), /*account_id*/ None, @@ -1853,7 +1951,8 @@ async fn websocket_harness_with_provider_options( let summary = ReasoningSummary::Auto; let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + session_id, + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), SessionSource::Exec, @@ -1866,7 +1965,8 @@ async fn websocket_harness_with_provider_options( WebsocketTestHarness { _codex_home: codex_home, client, - conversation_id, + session_id, + thread_id, model_info, effort, summary, @@ -1935,7 +2035,7 @@ async fn stream_until_complete_with_request_metadata( &harness.session_telemetry, harness.effort, harness.summary, - service_tier, + service_tier.map(|service_tier| service_tier.request_value().to_string()), turn_metadata_header, &codex_rollout_trace::InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 38671bd763..3da841ba79 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -7,6 +7,7 @@ use anyhow::Result; use codex_core::compact::SUMMARY_PREFIX; use codex_features::Feature; use codex_login::CodexAuth; +use codex_protocol::config_types::ServiceTier; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; @@ -105,6 +106,23 @@ fn contains_defer_loading(value: &Value) -> bool { } } +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut entries = map.iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.clone(), canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(), + } +} + const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; const DUMMY_FUNCTION_NAME: &str = "test_tool"; const REMOTE_COMPACT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); @@ -273,6 +291,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await?; let codex = harness.test().codex.clone(); let session_id = harness.test().session_configured.session_id.to_string(); + let thread_id = harness.test().session_configured.thread_id.to_string(); let responses_mock = responses::mount_sse_sequence( harness.server(), @@ -341,6 +360,10 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { compact_request.header("session_id").as_deref(), Some(session_id.as_str()) ); + assert_eq!( + compact_request.header("thread_id").as_deref(), + Some(thread_id.as_str()) + ); let compact_body = compact_request.body_json(); assert_eq!( compact_body.get("model").and_then(|v| v.as_str()), @@ -412,6 +435,267 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { Ok(()) } +async fn assert_remote_manual_compact_request_parity( + auth: CodexAuth, + configured_service_tier: Option, + expected_service_tier: Option<&str>, + snapshot_name: &str, + scenario: &str, +) -> Result<()> { + let mut builder = test_codex().with_auth(auth); + if let Some(service_tier) = configured_service_tier { + builder = builder.with_config(move |config| { + config.service_tier = Some(service_tier.request_value().to_string()); + }); + } + let harness = TestCodexHarness::with_builder(builder).await?; + let codex = harness.test().codex.clone(); + let image_url = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + .to_string(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_assistant_message("turn-one-assistant", "TURN_ONE_ASSISTANT"), + responses::ev_completed("turn-one-response"), + ]), + responses::sse(vec![ + responses::ev_reasoning_item( + "turn-two-reasoning", + &["TURN_TWO_REASONING"], + &["turn two raw content"], + ), + responses::ev_assistant_message("turn-two-assistant", "TURN_TWO_ASSISTANT"), + responses::ev_completed("turn-two-response"), + ]), + responses::sse(vec![ + responses::ev_function_call("turn-three-call", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed("turn-three-call-response"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("turn-three-assistant", "TURN_THREE_ASSISTANT"), + responses::ev_completed("turn-three-final-response"), + ]), + responses::sse(vec![ + responses::ev_local_shell_call( + "turn-four-local-shell", + "completed", + vec!["/bin/echo", "TURN_FOUR_LOCAL_SHELL"], + ), + responses::ev_completed("turn-four-local-shell-response"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("turn-four-assistant", "TURN_FOUR_ASSISTANT"), + responses::ev_completed("turn-four-final-response"), + ]), + responses::sse(vec![ + responses::ev_reasoning_item( + "turn-five-reasoning", + &["TURN_FIVE_REASONING"], + &["turn five raw content"], + ), + responses::ev_assistant_message("turn-five-assistant", "TURN_FIVE_ASSISTANT"), + responses::ev_completed("turn-five-response"), + ]), + ], + ) + .await; + let compact_mock = responses::mount_compact_user_history_with_summary_once( + harness.server(), + "REMOTE_CACHE_TIER_SUMMARY", + ) + .await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_ONE_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![ + UserInput::Text { + text: "TURN_TWO_PREFIX".to_string(), + text_elements: Vec::new(), + }, + UserInput::Text { + text: "TURN_TWO_SUFFIX".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_THREE_TOOL_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![ + UserInput::Image { image_url }, + UserInput::Text { + text: "TURN_FOUR_IMAGE_USER".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_FIVE_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + let response_requests = responses_mock.requests(); + assert_eq!( + response_requests.len(), + 7, + "expected five turns with one unsupported tool continuation and one local shell continuation" + ); + assert_eq!( + compact_mock.requests().len(), + 1, + "expected exactly one remote compact request" + ); + let normal_request = response_requests + .last() + .cloned() + .expect("last turn request missing"); + let compact_request = compact_mock.single_request(); + let normal_body = normal_request.body_json(); + let compact_body = compact_request.body_json(); + + let mut expected_compact_body_without_input = normal_body.clone(); + let expected_compact_object = expected_compact_body_without_input + .as_object_mut() + .expect("responses request body should be an object"); + for field in [ + "input", + "client_metadata", + "include", + "store", + "stream", + "tool_choice", + ] { + expected_compact_object.remove(field); + } + if expected_service_tier.is_none() { + expected_compact_object.remove("service_tier"); + } + let mut compact_body_without_input = compact_body.clone(); + compact_body_without_input + .as_object_mut() + .expect("compact request body should be an object") + .remove("input"); + let canonical_compact_body_without_input = canonical_json(&compact_body_without_input); + let canonical_expected_compact_body_without_input = + canonical_json(&expected_compact_body_without_input); + + assert_eq!( + json!({ + "compact_body_without_input": canonical_compact_body_without_input, + "expected_compact_body_without_input": canonical_expected_compact_body_without_input, + "prompt_cache_key_matches_responses": compact_body["prompt_cache_key"] == normal_body["prompt_cache_key"], + "prompt_cache_key_present": compact_body["prompt_cache_key"].is_string(), + "service_tier": compact_body.get("service_tier").and_then(Value::as_str), + }), + json!({ + "compact_body_without_input": canonical_expected_compact_body_without_input, + "expected_compact_body_without_input": canonical_expected_compact_body_without_input, + "prompt_cache_key_matches_responses": true, + "prompt_cache_key_present": true, + "service_tier": expected_service_tier, + }), + "compact requests should carry the same shared request fields as /responses" + ); + + insta::assert_snapshot!( + snapshot_name, + context_snapshot::format_request_body_diff_snapshot( + scenario, + "Last Normal /responses Request", + &normal_request, + "Remote /responses/compact Request", + &compact_request, + &ContextSnapshotOptions::default(), + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_api_auth_reuses_prompt_cache_key() -> Result<()> { + skip_if_no_network!(Ok(())); + + assert_remote_manual_compact_request_parity( + CodexAuth::from_api_key("dummy"), + Some(ServiceTier::Fast), + Some("priority"), + "remote_manual_compact_api_auth_prompt_cache_key_request_diff", + "After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.", + ) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache_key() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + assert_remote_manual_compact_request_parity( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + Some(ServiceTier::Fast), + Some("priority"), + "remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff", + "After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.", + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); @@ -713,6 +997,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { .await?; let codex = harness.test().codex.clone(); let session_id = harness.test().session_configured.session_id.to_string(); + let thread_id = harness.test().session_configured.thread_id.to_string(); mount_sse_once( harness.server(), @@ -764,6 +1049,10 @@ async fn remote_compact_runs_automatically() -> Result<()> { .as_deref(), Some(session_id.as_str()) ); + assert_eq!( + compact_mock.single_request().header("thread_id").as_deref(), + Some(thread_id.as_str()) + ); let follow_up_request = responses_mock.single_request(); let follow_up_body = follow_up_request.body_json().to_string(); assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY")); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 354e9a6a03..2788c8ef21 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -855,6 +855,7 @@ async fn fork_thread( nth_user_message, config.clone(), path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, )) diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 52041fe453..0ef7ddc339 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -115,38 +115,6 @@ async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn emits_deprecation_notice_for_notify() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - - let mut builder = test_codex().with_config(|config| { - config.notify = Some(vec!["notify-send".to_string(), "Codex".to_string()]); - }); - - let TestCodex { codex, .. } = builder.build(&server).await?; - - let notice = wait_for_event_match(&codex, |event| match event { - EventMsg::DeprecationNotice(ev) if ev.summary.contains("`notify`") => Some(ev.clone()), - _ => None, - }) - .await; - - let DeprecationNoticeEvent { summary, details } = notice; - assert_eq!( - summary, - "`notify` is deprecated and will be removed in a future release.".to_string(), - ); - assert_eq!( - details.as_deref(), - Some( - "Switch to a `Stop` hook for end-of-turn automation. See https://developers.openai.com/codex/hooks." - ), - ); - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 19ed2a2088..37456dce63 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -101,6 +101,7 @@ async fn fork_thread_twice_drops_to_first_message() { ForkSnapshot::TruncateBeforeNthUserMessage(1), config_for_fork.clone(), base_path.clone(), + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -125,6 +126,7 @@ async fn fork_thread_twice_drops_to_first_message() { ForkSnapshot::TruncateBeforeNthUserMessage(0), config_for_fork.clone(), fork1_path.clone(), + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -193,10 +195,11 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { ForkSnapshot::Interrupted, test.config.clone(), InitialHistory::Resumed(ResumedHistory { - conversation_id: test.session_configured.session_id, + conversation_id: test.session_configured.thread_id, history: source_items.clone(), rollout_path: None, }), + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 695e908ed8..92c8c10a0f 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -3,8 +3,11 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; +use codex_core::config::Config; use codex_core::config::Constrained; use codex_features::Feature; +use codex_plugin::PluginHookSource; +use codex_plugin::PluginId; use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; @@ -16,6 +19,9 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::hooks::trust_hooks; use core_test_support::managed_network_requirements_loader; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; @@ -67,6 +73,23 @@ fn network_workspace_write_profile() -> PermissionProfile { ) } +fn trust_plugin_hooks(config: &mut Config, plugin_hook_sources: Vec) { + if let Err(err) = config.features.enable(Feature::CodexHooks) { + panic!("test config should allow feature update: {err}"); + } + let listed = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: true, + config_layer_stack: Some(config.config_layer_stack.clone()), + plugin_hook_sources, + ..codex_hooks::HooksConfig::default() + }); + assert!( + !listed.hooks.is_empty(), + "trusted plugin hook fixture should discover at least one hook" + ); + trust_hooks(config, listed.hooks); +} + fn write_stop_hook(home: &Path, block_prompts: &[&str]) -> Result<()> { let script_path = home.join("stop_hook.py"); let log_path = home.join("stop_hook_log.jsonl"); @@ -237,6 +260,22 @@ if mode == "json_deny": "permissionDecisionReason": reason }} }})) +elif mode == "context": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PreToolUse", + "additionalContext": reason + }} + }})) +elif mode == "json_deny_with_context": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + "additionalContext": reason + }} + }})) elif mode == "exit_2": sys.stderr.write(reason + "\n") raise SystemExit(2) @@ -585,6 +624,38 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: Ok(()) } +fn write_session_start_hook_with_context(home: &Path, additional_context: &str) -> Result<()> { + let script_path = home.join("session_start_hook.py"); + let additional_context_json = serde_json::to_string(additional_context) + .context("serialize session start additional context for test")?; + let script = format!( + r#"import json + +print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "SessionStart", + "additionalContext": {additional_context_json} + }} +}})) +"#, + ); + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running session start hook", + }] + }] + } + }); + + fs::write(&script_path, script).context("write session start hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn rollout_hook_prompt_texts(text: &str) -> Result> { let mut texts = Vec::new(); for line in text.lines() { @@ -618,6 +689,11 @@ fn request_hook_prompt_texts( .collect() } +fn spilled_hook_output_path(text: &str) -> Option<&str> { + text.lines() + .find_map(|line| line.strip_prefix("Full hook output saved to: ")) +} + fn read_stop_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("stop_hook_log.jsonl")) .context("read stop hook log")? @@ -782,12 +858,7 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> { panic!("failed to write stop hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("hello from the sea").await?; @@ -881,12 +952,7 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> { panic!("failed to write session start hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("hello").await?; @@ -905,6 +971,101 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> { Ok(()) } +#[tokio::test] +async fn session_start_hook_spills_large_additional_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello from the reef"), + ev_completed("resp-1"), + ]), + ) + .await; + let additional_context = "remember the reef ".repeat(800); + + let mut builder = test_codex() + .with_pre_build_hook({ + let additional_context = additional_context.clone(); + move |home| { + if let Err(error) = write_session_start_hook_with_context(home, &additional_context) + { + panic!("failed to write session start hook test fixture: {error}"); + } + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let request = response.single_request(); + let developer_messages = request.message_input_texts("developer"); + let developer_message = developer_messages + .iter() + .find(|message| spilled_hook_output_path(message).is_some()) + .context("spilled developer hook message")?; + assert!(developer_message.contains("tokens truncated")); + let path = spilled_hook_output_path(developer_message).context("spill path")?; + assert_eq!(fs::read_to_string(path)?, additional_context); + + Ok(()) +} + +#[tokio::test] +async fn stop_hook_spills_large_continuation_prompt() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "draft one"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "draft two"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let continuation_prompt = std::iter::repeat_n("retry with the reef note", 800) + .collect::>() + .join(" "); + + let mut builder = test_codex() + .with_pre_build_hook({ + let continuation_prompt = continuation_prompt.clone(); + move |home| { + if let Err(error) = write_stop_hook(home, &[&continuation_prompt]) { + panic!("failed to write stop hook test fixture: {error}"); + } + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("hello from the sea").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let hook_prompt_texts = request_hook_prompt_texts(&requests[1]); + assert_eq!(hook_prompt_texts.len(), 1); + let hook_prompt_text = &hook_prompt_texts[0]; + assert!(hook_prompt_text.contains("tokens truncated")); + let path = spilled_hook_output_path(hook_prompt_text).context("spill path")?; + assert_eq!(fs::read_to_string(path)?, continuation_prompt); + + Ok(()) +} + #[tokio::test] async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()> { skip_if_no_network!(Ok(())); @@ -933,12 +1094,7 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<() panic!("failed to write stop hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let initial = initial_builder.build(&server).await?; let home = initial.home.clone(); let rollout_path = initial @@ -961,12 +1117,7 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<() ) .await; - let mut resume_builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + let mut resume_builder = test_codex().with_config(trust_discovered_hooks); let resumed = resume_builder.resume(&server, home, rollout_path).await?; resumed.submit_turn("and now continue").await?; @@ -1012,12 +1163,7 @@ async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() - panic!("failed to write parallel stop hook fixtures: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("hello again").await?; @@ -1070,12 +1216,7 @@ async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() panic!("failed to write user prompt submit hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("blocked first prompt").await?; @@ -1177,12 +1318,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu panic!("failed to write user prompt submit hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build_with_streaming_server(&server).await?; test.codex @@ -1331,12 +1467,7 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() -> panic!("failed to write permission request hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; fs::write(&marker, "seed").context("create permission request marker")?; @@ -1418,10 +1549,7 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result }) .with_config(|config| { config.include_apply_patch_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); }); let test = builder.build(&server).await?; let target_path = test.workspace_path(&patch_path); @@ -1495,10 +1623,7 @@ async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> { }) .with_config(|config| { config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); config .features .enable(Feature::UnifiedExec) @@ -1583,10 +1708,7 @@ allow_local_binding = true }) .with_cloud_requirements(managed_network_requirements_loader()) .with_config(move |config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .permissions @@ -1695,12 +1817,7 @@ async fn permission_request_hook_sees_retry_context_after_sandbox_denial() -> Re panic!("failed to write permission request hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; let marker_path = test.workspace_path(marker); let _ = fs::remove_file(&marker_path); @@ -1767,12 +1884,7 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { panic!("failed to write pre tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; if marker.exists() { @@ -1831,6 +1943,148 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { Ok(()) } +#[tokio::test] +async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-shell-command-context"; + let command = "printf pre-tool-output".to_string(); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "pre hook context observed"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let pre_context = "Remember the bash pre-tool note."; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "context", pre_context) + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("run the shell command with pre hook") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + assert!( + requests[1] + .message_input_texts("developer") + .contains(&pre_context.to_string()), + "follow-up request should include pre tool use additional context", + ); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell command output string"); + assert!( + output.contains("pre-tool-output"), + "shell command output should still reach the model", + ); + + Ok(()) +} + +#[tokio::test] +async fn blocked_pre_tool_use_records_additional_context_for_shell_command() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-shell-command-blocked-context"; + let marker = std::env::temp_dir().join("pretooluse-shell-command-blocked-context-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "blocked pre hook context observed"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let pre_context = "blocked by pre hook with context"; + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny_with_context", pre_context) + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover pre tool use marker")?; + } + + test.submit_turn_with_permission_profile( + "run the blocked shell command with pre hook context", + PermissionProfile::Disabled, + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + assert!( + requests[1] + .message_input_texts("developer") + .contains(&pre_context.to_string()), + "follow-up request should include blocked pre tool use additional context", + ); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell command output string"); + assert!( + output.contains("Command blocked by PreToolUse hook: blocked by pre hook with context"), + "blocked tool output should still surface the hook reason", + ); + assert!( + !marker.exists(), + "blocked command should not create marker file" + ); + + Ok(()) +} + #[tokio::test] async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1905,9 +2159,7 @@ print(json.dumps({{ ), ) .context("write plugin pre tool use hook script")?; - fs::write( - hooks_dir.join("hooks.json"), - r#"{ + let plugin_hooks_json = r#"{ "hooks": { "PreToolUse": [{ "matcher": "^Bash$", @@ -1917,21 +2169,34 @@ print(json.dumps({{ }] }] } -}"#, - ) - .context("write plugin hooks config")?; +}"#; + let plugin_hooks_path = hooks_dir.join("hooks.json"); + fs::write(&plugin_hooks_path, plugin_hooks_json).context("write plugin hooks config")?; + let plugin_root_abs = + AbsolutePathBuf::try_from(plugin_root.clone()).context("absolute plugin root")?; + let plugin_hooks_path_abs = + AbsolutePathBuf::try_from(plugin_hooks_path).context("absolute plugin hooks path")?; + let plugin_data_root = + AbsolutePathBuf::try_from(plugin_root.join("data")).context("absolute plugin data root")?; + let plugin_hook_sources = vec![PluginHookSource { + plugin_id: PluginId::parse("sample@test").context("plugin id")?, + plugin_root: plugin_root_abs, + plugin_data_root, + source_path: plugin_hooks_path_abs, + source_relative_path: "hooks/hooks.json".to_string(), + hooks: serde_json::from_str::(plugin_hooks_json) + .context("parse plugin hooks")? + .hooks, + }]; let mut builder = test_codex() .with_home(Arc::clone(&home)) - .with_config(|config| { + .with_config(move |config| { config .features .enable(Feature::Plugins) .expect("test config should allow feature update"); - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_plugin_hooks(config, plugin_hook_sources); config .features .enable(Feature::PluginHooks) @@ -2005,18 +2270,20 @@ async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> { ) .await; - let mut builder = test_codex().with_pre_build_hook(|home| { - if let Err(error) = write_pre_tool_use_hook_toml( - home, - "pre_tool_use_config_hook.py", - "pre_tool_use_config_hook_log.jsonl", - Some("^Bash$"), - "json_deny", - "blocked by config toml hook", - ) { - panic!("failed to write config.toml hook test fixture: {error}"); - } - }); + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_pre_tool_use_hook_toml( + home, + "pre_tool_use_config_hook.py", + "pre_tool_use_config_hook_log.jsonl", + Some("^Bash$"), + "json_deny", + "blocked by config toml hook", + ) { + panic!("failed to write config.toml hook test fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; if marker.exists() { @@ -2087,21 +2354,23 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> { ) .await; - let mut builder = test_codex().with_pre_build_hook(|home| { - if let Err(error) = write_pre_tool_use_hook(home, Some("^Bash$"), "allow", "unused") { - panic!("failed to write hooks.json hook fixture: {error}"); - } - if let Err(error) = write_pre_tool_use_hook_toml( - home, - "pre_tool_use_toml_hook.py", - "pre_tool_use_toml_hook_log.jsonl", - Some("^Bash$"), - "allow", - "unused", - ) { - panic!("failed to write config.toml hook fixture: {error}"); - } - }); + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_pre_tool_use_hook(home, Some("^Bash$"), "allow", "unused") { + panic!("failed to write hooks.json hook fixture: {error}"); + } + if let Err(error) = write_pre_tool_use_hook_toml( + home, + "pre_tool_use_toml_hook.py", + "pre_tool_use_toml_hook_log.jsonl", + Some("^Bash$"), + "allow", + "unused", + ) { + panic!("failed to write config.toml hook fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("run the shell command with merged hook sources") @@ -2200,12 +2469,7 @@ async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> { panic!("failed to write pre tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; if marker.exists() { @@ -2293,10 +2557,7 @@ async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { }) .with_config(|config| { config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); config .features .enable(Feature::UnifiedExec) @@ -2383,10 +2644,7 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> { }) .with_config(|config| { config.include_apply_patch_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -2457,10 +2715,7 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> { }) .with_config(|config| { config.include_apply_patch_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -2533,12 +2788,7 @@ async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> { panic!("failed to write pre tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("update the plan").await?; @@ -2602,12 +2852,7 @@ async fn post_tool_use_records_additional_context_for_shell_command() -> Result< panic!("failed to write post tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("run the shell command with post hook") @@ -2699,12 +2944,7 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason( panic!("failed to write post tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("run the shell command with blocking post hook") @@ -2768,12 +3008,7 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re panic!("failed to write post tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("run the shell command with stop-style post hook") @@ -2839,12 +3074,7 @@ async fn post_tool_use_records_additional_context_for_local_shell() -> Result<() panic!("failed to write post tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("run the local shell command with post hook") @@ -2912,10 +3142,7 @@ async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedb }) .with_config(|config| { config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); config .features .enable(Feature::UnifiedExec) @@ -2947,6 +3174,74 @@ async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedb Ok(()) } +#[tokio::test] +async fn post_tool_use_spills_large_feedback_message() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "posttooluse-large-feedback"; + let command = "printf post-hook-output".to_string(); + let args = serde_json::json!({ "cmd": command, "tty": false }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "exec_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "post hook blocked the exec result"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let feedback = "blocked by post hook ".repeat(800); + + let mut builder = test_codex() + .with_pre_build_hook({ + let feedback = feedback.clone(); + move |home| { + if let Err(error) = + write_post_tool_use_hook(home, Some("^Bash$"), "exit_2", &feedback) + { + panic!("failed to write post tool use hook test fixture: {error}"); + } + } + }) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + trust_discovered_hooks(config); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("run the exec command with long post-hook feedback") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("exec command output string"); + assert!(output.contains("tokens truncated")); + let path = spilled_hook_output_path(output).context("spill path")?; + assert_eq!(fs::read_to_string(path)?, feedback.trim()); + + Ok(()) +} + #[tokio::test] async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); @@ -3007,10 +3302,7 @@ async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> R }) .with_config(|config| { config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); config .features .enable(Feature::UnifiedExec) @@ -3094,10 +3386,7 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<() }) .with_config(|config| { config.include_apply_patch_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3185,10 +3474,7 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<( }) .with_config(|config| { config.include_apply_patch_tool = true; - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); + trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3261,12 +3547,7 @@ async fn post_tool_use_does_not_fire_for_plan_tool() -> Result<()> { panic!("failed to write post tool use hook test fixture: {error}"); } }) - .with_config(|config| { - config - .features - .enable(Feature::CodexHooks) - .expect("test config should allow feature update"); - }); + .with_config(trust_discovered_hooks); let test = builder.build(&server).await?; test.submit_turn("update the plan").await?; diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index 2157630e02..26e3053189 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -9,7 +9,7 @@ use codex_config::types::AppToolApproval; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; -use codex_features::Feature; +use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call_with_namespace; @@ -163,9 +163,7 @@ fn enable_hooks_and_rmcp_server( rmcp_test_server_bin: String, approval_mode: AppToolApproval, ) { - if let Err(err) = config.features.enable(Feature::CodexHooks) { - panic!("test config should allow feature update: {err}"); - } + trust_discovered_hooks(config); insert_rmcp_test_server(config, rmcp_test_server_bin, approval_mode); } diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 9530675acd..65087a1fe7 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -358,7 +358,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { let call_id = "ig_image_saved_to_temp_dir_default"; let expected_saved_path = image_generation_artifact_path( config.codex_home.as_path(), - &session_configured.session_id.to_string(), + &session_configured.thread_id.to_string(), call_id, ); let _ = std::fs::remove_file(&expected_saved_path); @@ -444,7 +444,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho } = test_codex().build(&server).await?; let expected_saved_path = image_generation_artifact_path( config.codex_home.as_path(), - &session_configured.session_id.to_string(), + &session_configured.thread_id.to_string(), "ig_invalid", ); let _ = std::fs::remove_file(&expected_saved_path); @@ -547,8 +547,8 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { }) .await; - let session_id = session_configured.session_id.to_string(); - assert_eq!(delta_event.thread_id, session_id); + let thread_id = session_configured.thread_id.to_string(); + assert_eq!(delta_event.thread_id, thread_id); assert_eq!(delta_event.turn_id, started_turn_id); assert_eq!(delta_event.item_id, started_item.id); assert_eq!(delta_event.delta, "streamed response"); @@ -614,7 +614,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< assert_eq!( plan_delta.thread_id, - session_configured.session_id.to_string() + session_configured.thread_id.to_string() ); assert_eq!(plan_delta.delta, "- Step 1\n- Step 2\n"); assert_eq!(plan_completed.text, "- Step 1\n- Step 2\n"); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 43ec50746e..e962b06d41 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -106,6 +106,7 @@ fn test_model_info( supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -486,7 +487,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_123", ); let _ = std::fs::remove_file(&saved_path); @@ -600,7 +601,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_123", ); let _ = std::fs::remove_file(&saved_path); @@ -716,7 +717,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_rollback", ); let _ = std::fs::remove_file(&saved_path); @@ -818,6 +819,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index e2688afc97..8463ee1cf1 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -341,6 +341,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { supported_in_api: true, priority, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index ac49b5334b..0f0dcf46f1 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -12,6 +12,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI; +use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call_with_namespace; @@ -162,9 +163,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res }) .with_config(move |config| { configure_apps(config, apps_server.chatgpt_base_url.as_str()); - if let Err(err) = config.features.enable(Feature::CodexHooks) { - panic!("test config should allow feature update: {err}"); - } + trust_discovered_hooks(config); }); let test = builder.build(&server).await?; tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?; diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index b582a2abc7..62851c515d 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -164,11 +164,11 @@ async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) { .await .unwrap_or_else(|err| panic!("submit queue-only agent mail: {err}")); codex - .submit(Op::ListMcpTools) + .submit(Op::RealtimeConversationListVoices) .await - .unwrap_or_else(|err| panic!("submit list-mcp-tools barrier: {err}")); + .unwrap_or_else(|err| panic!("submit list-voices barrier: {err}")); wait_for_event(codex, |event| { - matches!(event, EventMsg::McpListToolsResponse(_)) + matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_)) }) .await; } diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index bb93d5cbf8..4d6259a599 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -497,6 +497,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { ForkSnapshot::Interrupted, fork_config.clone(), rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index dde6d2ca51..09eb61fa69 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -579,6 +579,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow supported_in_api: true, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: Some(ModelMessages { @@ -687,6 +688,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - supported_in_api: true, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: Some(ModelMessages { diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 68121d2e12..25415cd7ee 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -13,6 +13,8 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::UserMessageEvent; +use codex_rollout::RolloutConfig; +use codex_rollout::state_db::StateDbHandle; use pretty_assertions::assert_eq; use std::io; use std::path::Path; @@ -26,6 +28,27 @@ async fn read_config_toml(codex_home: &Path) -> io::Result { toml::from_str(&contents).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } +async fn state_db_for_test(codex_home: &Path) -> io::Result { + let config = RolloutConfig { + codex_home: codex_home.to_path_buf(), + sqlite_home: codex_home.to_path_buf(), + cwd: codex_home.to_path_buf(), + model_provider_id: "openai".to_string(), + generate_memories: false, + }; + codex_rollout::state_db::try_init(&config) + .await + .map_err(io::Error::other) +} + +async fn run_migration( + codex_home: &Path, + config_toml: &ConfigToml, +) -> io::Result { + let state_db = state_db_for_test(codex_home).await?; + maybe_migrate_personality(codex_home, config_toml, state_db).await +} + async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> { let thread_id = ThreadId::new(); let dir = codex_home @@ -66,6 +89,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -111,6 +135,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -141,8 +166,7 @@ async fn migration_marker_exists_no_sessions_no_change() -> io::Result<()> { let marker_path = temp.path().join(PERSONALITY_MIGRATION_FILENAME); tokio::fs::write(&marker_path, "v1\n").await?; - let status = - maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; + let status = run_migration(temp.path(), &ConfigToml::default()).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); assert_eq!( @@ -156,8 +180,7 @@ async fn migration_marker_exists_no_sessions_no_change() -> io::Result<()> { async fn no_marker_no_sessions_no_change() -> io::Result<()> { let temp = TempDir::new()?; - let status = - maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; + let status = run_migration(temp.path(), &ConfigToml::default()).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); assert_eq!( @@ -176,8 +199,7 @@ async fn no_marker_sessions_sets_personality() -> io::Result<()> { let temp = TempDir::new()?; write_session_with_user_event(temp.path()).await?; - let status = - maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; + let status = run_migration(temp.path(), &ConfigToml::default()).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert_eq!( @@ -197,7 +219,7 @@ async fn no_marker_sessions_preserves_existing_config_fields() -> io::Result<()> tokio::fs::write(temp.path().join("config.toml"), "model = \"gpt-5.4\"\n").await?; let config_toml = read_config_toml(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let status = run_migration(temp.path(), &config_toml).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); let persisted = read_config_toml(temp.path()).await?; @@ -211,8 +233,7 @@ async fn no_marker_meta_only_rollout_is_treated_as_no_sessions() -> io::Result<( let temp = TempDir::new()?; write_session_with_meta_only(temp.path()).await?; - let status = - maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; + let status = run_migration(temp.path(), &ConfigToml::default()).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); assert_eq!( @@ -232,7 +253,7 @@ async fn no_marker_explicit_global_personality_skips_migration() -> io::Result<( write_session_with_user_event(temp.path()).await?; let config_toml = parse_config_toml("personality = \"friendly\"\n")?; - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let status = run_migration(temp.path(), &config_toml).await?; assert_eq!( status, @@ -262,7 +283,7 @@ personality = "friendly" "#, )?; - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let status = run_migration(temp.path(), &config_toml).await?; assert_eq!( status, @@ -285,7 +306,7 @@ async fn marker_short_circuits_invalid_profile_resolution() -> io::Result<()> { tokio::fs::write(temp.path().join(PERSONALITY_MIGRATION_FILENAME), "v1\n").await?; let config_toml = parse_config_toml("profile = \"missing\"\n")?; - let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; + let status = run_migration(temp.path(), &config_toml).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); Ok(()) @@ -296,7 +317,7 @@ async fn invalid_selected_profile_returns_error_and_does_not_write_marker() -> i let temp = TempDir::new()?; let config_toml = parse_config_toml("profile = \"missing\"\n")?; - let err = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None) + let err = run_migration(temp.path(), &config_toml) .await .expect_err("missing profile should fail"); @@ -313,10 +334,8 @@ async fn applied_migration_is_idempotent_on_second_run() -> io::Result<()> { let temp = TempDir::new()?; write_session_with_user_event(temp.path()).await?; - let first_status = - maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; - let second_status = - maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; + let first_status = run_migration(temp.path(), &ConfigToml::default()).await?; + let second_status = run_migration(temp.path(), &ConfigToml::default()).await?; assert_eq!(first_status, PersonalityMigrationStatus::Applied); assert_eq!(second_status, PersonalityMigrationStatus::SkippedMarker); @@ -330,8 +349,7 @@ async fn no_marker_archived_sessions_sets_personality() -> io::Result<()> { let temp = TempDir::new()?; write_archived_session_with_user_event(temp.path()).await?; - let status = - maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; + let status = run_migration(temp.path(), &ConfigToml::default()).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert_eq!( diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index c37fd8e378..0836909694 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -74,6 +74,7 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { "mcpServers": {{ "sample": {{ "command": "{command}", + "cwd": ".", "startup_timeout_sec": 60.0 }} }} @@ -98,20 +99,6 @@ fn write_plugin_app_plugin(home: &TempDir) { .expect("write plugin app config"); } -async fn build_plugin_test_codex( - server: &MockServer, - codex_home: Arc, -) -> Result> { - let mut builder = test_codex() - .with_home(codex_home) - .with_auth(CodexAuth::from_api_key("Test API Key")); - Ok(builder - .build(server) - .await - .expect("create new conversation") - .codex) -} - async fn build_analytics_plugin_test_codex( server: &MockServer, codex_home: Arc, @@ -449,34 +436,3 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn plugin_mcp_tools_are_listed() -> Result<()> { - skip_if_no_network!(Ok(())); - let server = start_mock_server().await; - let codex_home = Arc::new(TempDir::new()?); - let rmcp_test_server_bin = stdio_server_bin()?; - write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin); - let codex = build_plugin_test_codex(&server, codex_home).await?; - wait_for_sample_mcp_ready(&codex).await?; - - codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - let mut available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - available_tools.sort_unstable(); - assert!( - tool_list.tools.contains_key("mcp__sample__echo") - && tool_list.tools.contains_key("mcp__sample__image"), - "expected plugin MCP tools to be listed; discovered tools: {available_tools:?}" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index cc8e57f0f8..b81bb06bb9 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; +use codex_core::shell::default_user_shell; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -54,7 +55,7 @@ fn assert_default_env_context(text: &str, cwd: &str) { "expected cwd in environment context: {text}" ); assert!( - text.contains("bash"), + text.contains(&format!("{}", default_user_shell().name())), "expected shell in environment context: {text}" ); assert!( diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index dc506bc474..4fee438261 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -29,7 +29,6 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text: "hello from debug prompt".to_string(), text_elements: Vec::new(), }], - /*state_db*/ None, ) .await?; diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 36cc2e6812..0bd449188c 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -3,23 +3,52 @@ use anyhow::Result; use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::FileSystemSandboxContext; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; +use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; +use core_test_support::PathExt; use core_test_support::get_remote_test_env; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_env; use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::fs; use std::path::PathBuf; use std::process::Command; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use tempfile::TempDir; +async fn unified_exec_test(server: &wiremock::MockServer) -> Result { + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + let result = config.features.enable(Feature::UnifiedExec); + assert!( + result.is_ok(), + "unified exec should enable for test: {result:?}", + ); + }); + builder.build_remote_aware(server).await +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> { @@ -121,6 +150,114 @@ fn remote_exec(script: &str) -> Result<()> { Ok(()) } +async fn exec_command_routing_output( + test: &TestCodex, + server: &wiremock::MockServer, + call_id: &str, + arguments: Value, + environments: Option>, +) -> Result { + let response_mock = mount_sse_sequence( + server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?), + 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("route exec command", environments) + .await?; + + response_mock + .function_call_output_text(call_id) + .with_context(|| format!("missing function_call_output for {call_id}")) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let test = unified_exec_test(&server).await?; + let local_cwd = TempDir::new()?; + fs::write(local_cwd.path().join("marker.txt"), "local-routing")?; + let local_selection = TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: local_cwd.path().abs(), + }; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-remote-routing-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let remote_marker_name = "marker.txt"; + test.fs() + .create_directory( + &remote_cwd, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .write_file( + &remote_cwd.join(remote_marker_name), + b"remote-routing".to_vec(), + /*sandbox*/ None, + ) + .await?; + let remote_selection = TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: remote_cwd.clone(), + }; + let multi_env_output = exec_command_routing_output( + &test, + &server, + "call-multi-env", + json!({ + "shell": "/bin/sh", + "cmd": format!("cat {remote_marker_name}"), + "login": false, + "yield_time_ms": 1_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }), + Some(vec![local_selection, remote_selection]), + ) + .await?; + assert!( + multi_env_output.contains("remote-routing"), + "unexpected multi-env output: {multi_env_output}", + ); + assert!( + !multi_env_output.contains("local-routing"), + "multi-env command should not route to local: {multi_env_output}", + ); + + test.fs() + .remove( + &remote_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_test_env_sandboxed_read_allows_readable_root() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 49218c78d2..d1caf24836 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -533,6 +533,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -789,6 +790,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: remote_base.to_string(), model_messages: None, @@ -1279,6 +1281,7 @@ fn test_remote_model_with_policy( supports_search_tool: false, priority, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index cb545df351..02e7626d87 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -95,7 +95,8 @@ async fn emits_warning_when_resumed_model_differs() { let thread_manager = codex_core::test_support::thread_manager_with_models_provider( CodexAuth::from_api_key("test"), config.model_provider.clone(), - ); + ) + .await; let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("test")); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 0947f4fba7..1a7062bdb1 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -242,31 +242,42 @@ fn copy_binary_to_remote_env( Ok(remote_path) } -async fn wait_for_mcp_tool(fixture: &TestCodex, tool_name: &str) -> anyhow::Result<()> { - let tools_ready_deadline = Instant::now() + Duration::from_secs(30); - loop { - fixture.codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - if tool_list.tools.contains_key(tool_name) { - return Ok(()); - } - - let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - if Instant::now() >= tools_ready_deadline { - panic!( - "timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}" - ); - } - sleep(Duration::from_millis(200)).await; +async fn wait_for_mcp_server(fixture: &TestCodex, server_name: &str) -> anyhow::Result<()> { + let startup_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| match ev { + EventMsg::McpStartupComplete(summary) => { + summary.ready.iter().any(|server| server == server_name) + || summary + .failed + .iter() + .any(|failure| failure.server == server_name) + || summary.cancelled.iter().any(|server| server == server_name) + } + _ => false, + }, + Duration::from_secs(70), + ) + .await; + let EventMsg::McpStartupComplete(summary) = startup_event else { + unreachable!("event guard guarantees McpStartupComplete"); + }; + if let Some(failure) = summary + .failed + .iter() + .find(|failure| failure.server == server_name) + { + let error = &failure.error; + anyhow::bail!("MCP server {server_name} failed to start: {error}"); } + if summary.cancelled.iter().any(|server| server == server_name) { + anyhow::bail!("MCP server {server_name} startup was cancelled"); + } + ensure!( + summary.ready.iter().any(|server| server == server_name), + "expected MCP server {server_name} to be ready; startup summary: {summary:?}" + ); + Ok(()) } #[derive(Default)] @@ -731,7 +742,6 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let call_id = "sandbox-meta-call"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}__"); - let tool_name = format!("{namespace}sandbox_meta"); let call_mock = mount_sse_once( &server, @@ -767,30 +777,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> .build_remote_aware(&server) .await?; - let tools_ready_deadline = Instant::now() + Duration::from_secs(30); - loop { - fixture.codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - if tool_list.tools.contains_key(&tool_name) { - break; - } - - let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - if Instant::now() >= tools_ready_deadline { - panic!( - "timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}" - ); - } - sleep(Duration::from_millis(200)).await; - } + wait_for_mcp_server(&fixture, server_name).await?; fixture .submit_turn_with_permission_profile( @@ -1039,7 +1026,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { let call_id = "img-1"; let server_name = "rmcp"; - let tool_name = format!("mcp__{server_name}__image"); let namespace = format!("mcp__{server_name}__"); // First stream: model decides to call the image tool. @@ -1086,7 +1072,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { }) .build_remote_aware(&server) .await?; - wait_for_mcp_tool(&fixture, &tool_name).await?; + wait_for_mcp_server(&fixture, server_name).await?; fixture .codex @@ -1176,7 +1162,6 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re let call_id = "img-original-detail-1"; let server_name = "rmcp"; - let tool_name = format!("mcp__{server_name}__image_scenario"); let namespace = format!("mcp__{server_name}__"); mount_sse_once( @@ -1219,7 +1204,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re }) .build_remote_aware(&server) .await?; - wait_for_mcp_tool(&fixture, &tool_name).await?; + wait_for_mcp_server(&fixture, server_name).await?; fixture .codex @@ -1283,6 +1268,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re supported_in_api: true, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -1954,7 +1940,6 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { let call_id = "call-789"; let server_name = "rmcp_http_oauth"; - let tool_name = format!("mcp__{server_name}__echo"); let namespace = format!("mcp__{server_name}__"); mount_sse_once( @@ -2035,9 +2020,9 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { }) .build_remote_aware(&server) .await?; - // Phase 5: wait for MCP discovery to publish the expected tool before the - // turn is submitted, which keeps failures tied to server startup/discovery. - wait_for_mcp_tool(&fixture, &tool_name).await?; + // Phase 5: wait for MCP startup before the turn is submitted, which keeps + // failures tied to server startup/discovery. + wait_for_mcp_server(&fixture, server_name).await?; // Phase 6: submit the user turn that should invoke the OAuth-backed tool. fixture diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index d0b39a9283..51b3f2db1a 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -186,6 +186,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> thread_id, /*forked_from_id*/ None, SessionSource::Exec, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 9573e1b586..5e2c7db346 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -570,6 +570,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let requests = mock.requests(); assert_eq!(requests.len(), 3); + let first_request_body = requests[0].body_json(); let apps_tool_call = server .received_requests() @@ -597,6 +598,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"), Some(&json!(test.session_configured.session_id.to_string())) ); + assert_eq!( + apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/thread_id"), + Some(&json!(test.session_configured.thread_id.to_string())) + ); assert!( apps_tool_call .pointer("/params/_meta/x-codex-turn-metadata/turn_id") @@ -604,6 +609,22 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - .is_some_and(|turn_id| !turn_id.is_empty()), "apps tools/call should include turn metadata turn_id: {apps_tool_call:?}" ); + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/model") + .and_then(Value::as_str), + Some("gpt-5.4") + ); + let first_request_reasoning_effort = first_request_body + .pointer("/reasoning/effort") + .and_then(Value::as_str) + .expect("first response request should include reasoning effort"); + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/reasoning_effort") + .and_then(Value::as_str), + Some(first_request_reasoning_effort) + ); let mcp_turn_started_at_unix_ms = apps_tool_call .pointer("/params/_meta/x-codex-turn-metadata/turn_started_at_unix_ms") .and_then(Value::as_i64) @@ -626,7 +647,6 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - Some(mcp_turn_started_at_unix_ms) ); - let first_request_body = requests[0].body_json(); let first_request_tools = tool_names(&first_request_body); assert!( first_request_tools diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index add1ee6ea7..894110a6fb 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -2,20 +2,13 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Result; -use codex_core::ThreadManager; -use codex_core::thread_store_from_config; use codex_exec_server::CreateDirectoryOptions; -use codex_exec_server::EnvironmentManager; -use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::ExecutorFileSystem; -use codex_login::CodexAuth; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Op; -use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -25,11 +18,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; -use pretty_assertions::assert_eq; -use std::fs; -use std::path::Path; use std::sync::Arc; -use tempfile::TempDir; async fn write_repo_skill( cwd: AbsolutePathBuf, @@ -52,22 +41,6 @@ async fn write_repo_skill( Ok(()) } -fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) -> Result<()> { - let skill_dir = codex_home.join("skills").join(dir); - fs::create_dir_all(&skill_dir)?; - let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); - fs::write(skill_dir.join("SKILL.md"), contents)?; - Ok(()) -} - -fn system_skill_md_path(home: impl AsRef, name: &str) -> std::path::PathBuf { - home.as_ref() - .join("skills") - .join(".system") - .join(name) - .join("SKILL.md") -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_includes_skill_instructions() -> Result<()> { skip_if_no_network!(Ok(())); @@ -148,261 +121,3 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_skills_includes_repo_and_home_skills_remote_aware() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex() - .with_pre_build_hook(|home| { - write_home_skill(home, "home-demo", "home-demo", "from home") - .expect("write home skill"); - }) - .with_workspace_setup(|cwd, fs| async move { - write_repo_skill(cwd, fs, "repo-demo", "from repo", "# Body").await - }); - let test = builder.build_remote_aware(&server).await?; - - test.codex - .submit(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }) - .await?; - let response = - core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - let cwd = test.config.cwd.as_path(); - let skills = response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| entry.skills.clone()) - .unwrap_or_default(); - - let repo_skill = skills - .iter() - .find(|skill| skill.name == "repo-demo") - .expect("expected repo skill"); - assert_eq!(repo_skill.scope, codex_protocol::protocol::SkillScope::Repo); - let repo_path = repo_skill.path.to_string_lossy().replace('\\', "/"); - assert!( - repo_path.ends_with("/.agents/skills/repo-demo/SKILL.md"), - "unexpected repo skill path: {repo_path}" - ); - - let home_skill = skills - .iter() - .find(|skill| skill.name == "home-demo") - .expect("expected home skill"); - assert_eq!(home_skill.scope, codex_protocol::protocol::SkillScope::User); - let home_path = home_skill.path.to_string_lossy().replace('\\', "/"); - assert!( - home_path.ends_with("/skills/home-demo/SKILL.md"), - "unexpected home skill path: {home_path}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - write_home_skill( - codex_home.path(), - "home-disabled", - "home-disabled", - "from home", - )?; - let repo_skill_dir = cwd - .path() - .join(".agents") - .join("skills") - .join("repo-disabled"); - fs::create_dir_all(&repo_skill_dir)?; - fs::write( - repo_skill_dir.join("SKILL.md"), - "---\nname: repo-disabled\ndescription: from repo\n---\n\n# Body\n", - )?; - let mut config = load_default_config_for_test(&codex_home).await; - config.cwd = AbsolutePathBuf::from_absolute_path_checked(cwd.path())?; - - let thread_manager = ThreadManager::new( - &config, - codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")), - SessionSource::Exec, - Arc::new(EnvironmentManager::disabled_for_tests( - ExecServerRuntimePaths::new( - std::env::current_exe()?, - /*codex_linux_sandbox_exe*/ None, - )?, - )), - /*analytics_events_client*/ None, - thread_store_from_config(&config, /*state_db*/ None), - /*state_db*/ None, - ); - let new_thread = thread_manager.start_thread(config.clone()).await?; - let cwd = config.cwd.to_path_buf(); - - new_thread - .thread - .submit(Op::ListSkills { - cwds: vec![cwd.clone()], - force_reload: true, - }) - .await?; - let response = - core_test_support::wait_for_event_match(new_thread.thread.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - assert_eq!(response.skills.len(), 1); - assert_eq!(response.skills[0].cwd, cwd); - assert_eq!(response.skills[0].errors.len(), 0); - assert!( - response.skills[0] - .skills - .iter() - .any(|skill| skill.name == "home-disabled") - ); - assert!( - response.skills[0] - .skills - .iter() - .all(|skill| skill.name != "repo-disabled") - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn skill_load_errors_surface_in_session_configured() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_pre_build_hook(|home| { - let skill_dir = home.join("skills").join("broken"); - fs::create_dir_all(&skill_dir).unwrap(); - fs::write(skill_dir.join("SKILL.md"), "not yaml").unwrap(); - }); - let test = builder.build(&server).await?; - - test.codex - .submit(Op::ListSkills { - cwds: Vec::new(), - force_reload: false, - }) - .await?; - let response = - core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - let cwd = test.cwd_path(); - let (skills, errors) = response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| (entry.skills.clone(), entry.errors.clone())) - .unwrap_or_default(); - - assert!( - skills.iter().all(|skill| { - !skill - .path - .to_string_lossy() - .ends_with("skills/broken/SKILL.md") - }), - "expected broken skill not loaded, got {skills:?}" - ); - assert_eq!(errors.len(), 1, "expected one load error"); - let error_path = errors[0].path.to_string_lossy(); - assert!( - error_path.ends_with("skills/broken/SKILL.md"), - "unexpected error path: {error_path}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_skills_includes_system_cache_entries() -> Result<()> { - skip_if_no_network!(Ok(())); - - const SYSTEM_SKILL_NAME: &str = "skill-creator"; - - let server = start_mock_server().await; - let mut builder = test_codex().with_pre_build_hook(|home| { - let system_skill_path = system_skill_md_path(home, SYSTEM_SKILL_NAME); - assert!( - !system_skill_path.exists(), - "expected embedded system skills not yet installed, but {system_skill_path:?} exists" - ); - }); - let test = builder.build(&server).await?; - - let system_skill_path = system_skill_md_path(test.codex_home_path(), SYSTEM_SKILL_NAME); - assert!( - system_skill_path.exists(), - "expected embedded system skills installed to {system_skill_path:?}" - ); - let system_skill_contents = fs::read_to_string(&system_skill_path)?; - let expected_name_line = format!("name: {SYSTEM_SKILL_NAME}"); - assert!( - system_skill_contents.contains(&expected_name_line), - "expected embedded system skill file, got:\n{system_skill_contents}" - ); - - test.codex - .submit(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }) - .await?; - let response = - core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - let cwd = test.cwd_path(); - let (skills, _errors) = response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| (entry.skills.clone(), entry.errors.clone())) - .unwrap_or_default(); - - let skill = skills - .iter() - .find(|skill| skill.name == SYSTEM_SKILL_NAME) - .expect("expected system skill to be present"); - assert_eq!(skill.scope, codex_protocol::protocol::SkillScope::System); - let path_str = skill.path.to_string_lossy().replace('\\', "/"); - let expected_path_suffix = format!("/skills/.system/{SYSTEM_SKILL_NAME}/SKILL.md"); - assert!( - path_str.ends_with(&expected_path_suffix), - "unexpected skill path: {path_str}" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap new file mode 100644 index 0000000000..4527899106 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)" +--- +Scenario: After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields. + +--- Last Normal /responses Request ++++ Remote /responses/compact Request +- "client_metadata": { +- "x-codex-installation-id": "" +- }, +- "include": [ +- "reasoning.encrypted_content" +- ], ++ }, ++ { ++ "content": [ ++ { ++ "text": "turn five raw content", ++ "type": "reasoning_text" ++ } ++ ], ++ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "summary": [ ++ { ++ "text": "TURN_FIVE_REASONING", ++ "type": "summary_text" ++ } ++ ], ++ "type": "reasoning" ++ }, ++ { ++ "content": [ ++ { ++ "text": "TURN_FIVE_ASSISTANT", ++ "type": "output_text" ++ } ++ ], ++ "role": "assistant", ++ "type": "message" +- "store": false, +- "stream": true, +- "tool_choice": "auto", diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap new file mode 100644 index 0000000000..40baae2674 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)" +--- +Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields. + +--- Last Normal /responses Request ++++ Remote /responses/compact Request +- "client_metadata": { +- "x-codex-installation-id": "" +- }, +- "include": [ +- "reasoning.encrypted_content" +- ], ++ }, ++ { ++ "content": [ ++ { ++ "text": "turn five raw content", ++ "type": "reasoning_text" ++ } ++ ], ++ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "summary": [ ++ { ++ "text": "TURN_FIVE_REASONING", ++ "type": "summary_text" ++ } ++ ], ++ "type": "reasoning" ++ }, ++ { ++ "content": [ ++ { ++ "text": "TURN_FIVE_ASSISTANT", ++ "type": "output_text" ++ } ++ ], ++ "role": "assistant", ++ "type": "message" +- "store": false, +- "stream": true, +- "tool_choice": "auto", diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 031c3135e8..cc5a9952e3 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -67,6 +67,7 @@ fn test_model_info( supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 8250f5493d..b2a2778047 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -48,7 +48,7 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> { }); let test = builder.build(&server).await?; - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let rollout_path = test.codex.rollout_path().expect("rollout path"); let db_path = codex_state::state_db_path(test.config.sqlite_home.as_path()); @@ -144,6 +144,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -261,7 +262,7 @@ async fn user_messages_persist_in_state_db() -> Result<()> { test.submit_turn("another message").await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let mut metadata = None; for _ in 0..100 { @@ -304,7 +305,7 @@ async fn web_search_marks_thread_memory_mode_polluted_when_configured() -> Resul }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; test.submit_turn("search the web").await?; @@ -396,7 +397,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let cwd = test.cwd_path().to_path_buf(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); @@ -477,7 +478,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> { }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let expected_thread_id = test.session_configured.session_id.to_string(); + let expected_thread_id = test.session_configured.thread_id.to_string(); test.submit_turn("run a shell command").await?; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 3f457967c1..3a0c37acc7 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -116,7 +116,7 @@ async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let ids = test.thread_manager.list_thread_ids().await; if let Some(spawned_id) = ids .iter() - .find(|id| **id != test.session_configured.session_id) + .find(|id| **id != test.session_configured.thread_id) { return Ok(spawned_id.to_string()); } diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index 66a7366589..7a4924cdda 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -34,7 +34,8 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { let thread_manager = codex_core::test_support::thread_manager_with_models_provider( CodexAuth::from_api_key("test"), config.model_provider.clone(), - ); + ) + .await; let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("test")); @@ -81,7 +82,8 @@ async fn suppresses_warning_when_configured() { let thread_manager = codex_core::test_support::thread_manager_with_models_provider( CodexAuth::from_api_key("test"), config.model_provider.clone(), - ); + ) + .await; let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("test")); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 29c660d3af..19c2e66730 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1085,6 +1085,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index de52821839..d0e207d963 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -72,6 +72,7 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( /*snapshot*/ 0usize, resumed.config.clone(), rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 9f238b40ee..d18aa669ca 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -70,7 +70,6 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - - `Op::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) - `Op::UserTurn` and `Op::OverrideTurnContext` accept an optional `personality` override that updates the model’s communication style Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` is selected, the personality placeholder is replaced with an empty string. @@ -86,7 +85,6 @@ Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user - `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the turn. This can be used to continue the turn at a later point in time, perhaps with additional user input. - - `EventMsg::ListSkillsResponse` – Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`) ### UserInput items diff --git a/codex-rs/exec-server/BUILD.bazel b/codex-rs/exec-server/BUILD.bazel index 57ebe041f8..5f3efe6211 100644 --- a/codex-rs/exec-server/BUILD.bazel +++ b/codex-rs/exec-server/BUILD.bazel @@ -7,5 +7,8 @@ codex_rust_crate( # they install process-global test-binary dispatch state, and the remote # exec-server cases already rely on serialization around the full CLI path. integration_test_args = ["--test-threads=1"], + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], test_tags = ["no-sandbox"], ) diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 5f31ca4329..1495397c78 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -23,9 +23,10 @@ codex-sandboxing = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } futures = { workspace = true } -reqwest = { workspace = true, features = ["rustls-tls", "stream"] } +reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = [ "fs", @@ -51,3 +52,4 @@ pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" +wiremock = { workspace = true } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 78b92e1a71..81664eaca0 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -22,6 +22,11 @@ the wire. The CLI entrypoint supports: - `ws://IP:PORT` (default) +- `--remote URL --executor-id ID [--name NAME]` + +Remote mode registers the local exec-server with the executor registry, +then reconnects to the service-provided rendezvous websocket as the executor. +It requires a bearer token in `CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN`. Wire framing: @@ -308,6 +313,8 @@ The crate exports: - `DEFAULT_LISTEN_URL` and `ExecServerListenUrlParseError` - `ExecServerRuntimePaths` - `run_main()` for embedding the websocket server +- `RemoteExecutorConfig` and `run_remote_executor()` for embedding remote + registration mode Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level `codex exec-server` command builds these paths from the `codex` arg0 dispatch diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index f26069ac7a..47359393d3 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -254,6 +254,18 @@ pub enum ExecServerError { Protocol(String), #[error("exec-server rejected request ({code}): {message}")] Server { code: i64, message: String }, + #[error("executor registry request failed ({status}{code_suffix}): {message}", code_suffix = .code.as_ref().map(|code| format!(", {code}")).unwrap_or_default())] + ExecutorRegistryHttp { + status: reqwest::StatusCode, + code: Option, + message: String, + }, + #[error("executor registry configuration error: {0}")] + ExecutorRegistryConfig(String), + #[error("executor registry authentication error: {0}")] + ExecutorRegistryAuth(String), + #[error("executor registry request failed: {0}")] + ExecutorRegistryRequest(#[from] reqwest::Error), } impl ExecServerClient { diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 8f084a50e9..76b0f22b2b 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -29,6 +29,15 @@ use crate::rpc::internal_error; use crate::rpc::invalid_request; const FS_HELPER_ENV_ALLOWLIST: &[&str] = &["PATH", "TMPDIR", "TMP", "TEMP"]; +#[cfg(debug_assertions)] +const FS_HELPER_BAZEL_BWRAP_ENV_ALLOWLIST: &[&str] = &[ + "CARGO_BIN_EXE_bwrap", + "RUNFILES_DIR", + "RUNFILES_MANIFEST_FILE", + "RUNFILES_MANIFEST_ONLY", + "TEST_SRCDIR", + "TEST_WORKSPACE", +]; #[derive(Clone, Debug)] pub(crate) struct FileSystemSandboxRunner { @@ -220,7 +229,19 @@ fn helper_env_from_vars( } fn helper_env_key_is_allowed(key: &str) -> bool { - FS_HELPER_ENV_ALLOWLIST.contains(&key) || (cfg!(windows) && key.eq_ignore_ascii_case("PATH")) + FS_HELPER_ENV_ALLOWLIST.contains(&key) + || bazel_bwrap_env_key_is_allowed(key) + || (cfg!(windows) && key.eq_ignore_ascii_case("PATH")) +} + +#[cfg(debug_assertions)] +fn bazel_bwrap_env_key_is_allowed(key: &str) -> bool { + option_env!("BAZEL_PACKAGE").is_some() && FS_HELPER_BAZEL_BWRAP_ENV_ALLOWLIST.contains(&key) +} + +#[cfg(not(debug_assertions))] +fn bazel_bwrap_env_key_is_allowed(_key: &str) -> bool { + false } async fn run_command( diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 1550653d94..d860d59aba 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -11,6 +11,7 @@ mod local_process; mod process; mod process_id; mod protocol; +mod remote; mod remote_file_system; mod remote_process; mod rpc; @@ -87,6 +88,9 @@ pub use protocol::TerminateResponse; pub use protocol::WriteParams; pub use protocol::WriteResponse; pub use protocol::WriteStatus; +pub use remote::CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR; +pub use remote::RemoteExecutorConfig; +pub use remote::run_remote_executor; pub use runtime_paths::ExecServerRuntimePaths; pub use server::DEFAULT_LISTEN_URL; pub use server::ExecServerListenUrlParseError; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs new file mode 100644 index 0000000000..b574ced72f --- /dev/null +++ b/codex-rs/exec-server/src/remote.rs @@ -0,0 +1,392 @@ +use std::collections::BTreeMap; +use std::env; +use std::time::Duration; + +use reqwest::StatusCode; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use sha2::Digest as _; +use tokio::time::sleep; +use tokio_tungstenite::connect_async; +use tracing::warn; +use uuid::Uuid; + +use crate::ExecServerError; +use crate::ExecServerRuntimePaths; +use crate::connection::JsonRpcConnection; +use crate::server::ConnectionProcessor; + +pub const CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR: &str = + "CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN"; + +const PROTOCOL_VERSION: &str = "codex-exec-server-v1"; +const ERROR_BODY_PREVIEW_BYTES: usize = 4096; + +#[derive(Clone)] +struct ExecutorRegistryClient { + base_url: String, + bearer_token: String, + http: reqwest::Client, +} + +impl std::fmt::Debug for ExecutorRegistryClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExecutorRegistryClient") + .field("base_url", &self.base_url) + .field("bearer_token", &"") + .finish_non_exhaustive() + } +} + +impl ExecutorRegistryClient { + fn new(base_url: String, bearer_token: String) -> Result { + let base_url = normalize_base_url(base_url)?; + Ok(Self { + base_url, + bearer_token, + http: reqwest::Client::new(), + }) + } + + async fn register_executor( + &self, + request: &ExecutorRegistryRegisterExecutorRequest, + ) -> Result { + self.post_json( + &format!("/cloud/executor/{}/register", request.executor_id), + request, + ) + .await + } + + async fn post_json(&self, path: &str, request: &T) -> Result + where + T: Serialize + Sync, + R: for<'de> Deserialize<'de>, + { + let response = self + .http + .post(endpoint_url(&self.base_url, path)) + .bearer_auth(&self.bearer_token) + .json(request) + .send() + .await?; + + if response.status().is_success() { + return response.json::().await.map_err(ExecServerError::from); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + return Err(executor_registry_auth_error(status, &body)); + } + + Err(executor_registry_http_error(status, &body)) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +struct ExecutorRegistryRegisterExecutorRequest { + idempotency_id: String, + executor_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + labels: BTreeMap, + metadata: Value, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize)] +struct ExecutorRegistryExecutorRegistrationResponse { + id: String, + executor_id: String, + url: String, +} + +/// Configuration for registering an exec-server for remote use. +#[derive(Clone, Eq, PartialEq)] +pub struct RemoteExecutorConfig { + pub base_url: String, + pub executor_id: String, + pub name: String, + bearer_token: String, +} + +impl std::fmt::Debug for RemoteExecutorConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteExecutorConfig") + .field("base_url", &self.base_url) + .field("executor_id", &self.executor_id) + .field("name", &self.name) + .field("bearer_token", &"") + .finish() + } +} + +impl RemoteExecutorConfig { + pub fn new(base_url: String, executor_id: String) -> Result { + Self::with_bearer_token(base_url, executor_id, read_remote_bearer_token_from_env()?) + } + + fn with_bearer_token( + base_url: String, + executor_id: String, + bearer_token: String, + ) -> Result { + let executor_id = normalize_executor_id(executor_id)?; + let bearer_token = normalize_bearer_token(bearer_token)?; + Ok(Self { + base_url, + executor_id, + name: "codex-exec-server".to_string(), + bearer_token, + }) + } + + fn registration_request( + &self, + registration_id: Uuid, + ) -> ExecutorRegistryRegisterExecutorRequest { + ExecutorRegistryRegisterExecutorRequest { + idempotency_id: self.default_idempotency_id(registration_id), + executor_id: self.executor_id.clone(), + name: Some(self.name.clone()), + labels: BTreeMap::new(), + metadata: Value::Object(Default::default()), + } + } + + fn default_idempotency_id(&self, registration_id: Uuid) -> String { + let mut hasher = sha2::Sha256::new(); + hasher.update(self.executor_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(self.name.as_bytes()); + hasher.update(b"\0"); + hasher.update(PROTOCOL_VERSION); + hasher.update(b"\0"); + hasher.update(registration_id.as_bytes()); + let digest = hasher.finalize(); + format!("codex-exec-server-{digest:x}") + } +} + +/// Register an exec-server for remote use and serve requests over the returned +/// rendezvous websocket. +pub async fn run_remote_executor( + config: RemoteExecutorConfig, + runtime_paths: ExecServerRuntimePaths, +) -> Result<(), ExecServerError> { + let client = ExecutorRegistryClient::new(config.base_url.clone(), config.bearer_token.clone())?; + let processor = ConnectionProcessor::new(runtime_paths); + let registration_id = Uuid::new_v4(); + let mut backoff = Duration::from_secs(1); + + loop { + let request = config.registration_request(registration_id); + let response = client.register_executor(&request).await?; + eprintln!( + "codex exec-server remote executor {} registered with executor_id {}", + response.id, response.executor_id + ); + + match connect_async(response.url.as_str()).await { + Ok((websocket, _)) => { + backoff = Duration::from_secs(1); + processor + .run_connection(JsonRpcConnection::from_websocket( + websocket, + "remote exec-server websocket".to_string(), + )) + .await; + } + Err(err) => { + warn!("failed to connect remote exec-server websocket: {err}"); + } + } + + sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(30)); + } +} + +fn read_remote_bearer_token_from_env() -> Result { + read_remote_bearer_token_from_env_with(|name| env::var(name)) +} + +fn read_remote_bearer_token_from_env_with(get_var: F) -> Result +where + F: FnOnce(&str) -> Result, +{ + let bearer_token = get_var(CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR).map_err(|_| { + ExecServerError::ExecutorRegistryAuth(format!( + "executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is not set" + )) + })?; + normalize_bearer_token(bearer_token) +} + +fn normalize_bearer_token(bearer_token: String) -> Result { + let bearer_token = bearer_token.trim().to_string(); + if bearer_token.is_empty() { + return Err(ExecServerError::ExecutorRegistryAuth(format!( + "executor registry bearer token environment variable `{CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR}` is empty" + ))); + } + Ok(bearer_token) +} + +fn normalize_executor_id(executor_id: String) -> Result { + let executor_id = executor_id.trim().to_string(); + if executor_id.is_empty() { + return Err(ExecServerError::ExecutorRegistryConfig( + "executor id is required for remote exec-server registration".to_string(), + )); + } + Ok(executor_id) +} + +#[derive(Deserialize)] +struct RegistryErrorBody { + error: Option, +} + +#[derive(Deserialize)] +struct RegistryError { + code: Option, + message: Option, +} + +fn normalize_base_url(base_url: String) -> Result { + let trimmed = base_url.trim().trim_end_matches('/').to_string(); + if trimmed.is_empty() { + return Err(ExecServerError::ExecutorRegistryConfig( + "executor registry base URL is required".to_string(), + )); + } + Ok(trimmed) +} + +fn endpoint_url(base_url: &str, path: &str) -> String { + format!("{base_url}/{}", path.trim_start_matches('/')) +} + +fn executor_registry_auth_error(status: StatusCode, body: &str) -> ExecServerError { + let message = registry_error_message(body).unwrap_or_else(|| "empty error body".to_string()); + ExecServerError::ExecutorRegistryAuth(format!( + "executor registry authentication failed ({status}): {message}" + )) +} + +fn executor_registry_http_error(status: StatusCode, body: &str) -> ExecServerError { + let parsed = serde_json::from_str::(body).ok(); + let (code, message) = parsed + .and_then(|body| body.error) + .map(|error| { + ( + error.code, + error.message.unwrap_or_else(|| { + preview_error_body(body).unwrap_or_else(|| "empty error body".to_string()) + }), + ) + }) + .unwrap_or_else(|| { + ( + None, + preview_error_body(body) + .unwrap_or_else(|| "empty or malformed error body".to_string()), + ) + }); + ExecServerError::ExecutorRegistryHttp { + status, + code, + message, + } +} + +fn registry_error_message(body: &str) -> Option { + serde_json::from_str::(body) + .ok() + .and_then(|body| body.error) + .and_then(|error| error.message) + .or_else(|| preview_error_body(body)) +} + +fn preview_error_body(body: &str) -> Option { + let trimmed = body.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.chars().take(ERROR_BODY_PREVIEW_BYTES).collect()) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_json; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; + + use super::*; + + #[tokio::test] + async fn register_executor_posts_with_bearer_token_header() { + let server = MockServer::start().await; + let registration_id = Uuid::from_u128(1); + let config = RemoteExecutorConfig::with_bearer_token( + server.uri(), + "exec-requested".to_string(), + "registry-token".to_string(), + ) + .expect("config"); + let request = config.registration_request(registration_id); + let expected_request = serde_json::to_value(&request).expect("serialize request"); + Mock::given(method("POST")) + .and(path("/cloud/executor/exec-requested/register")) + .and(header("authorization", "Bearer registry-token")) + .and(body_json(expected_request)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "registration-1", + "executor_id": "exec-1", + "url": "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc" + }))) + .mount(&server) + .await; + let client = ExecutorRegistryClient::new(server.uri(), "registry-token".to_string()) + .expect("client"); + + let response = client + .register_executor(&request) + .await + .expect("register executor"); + + assert_eq!( + response, + ExecutorRegistryExecutorRegistrationResponse { + id: "registration-1".to_string(), + executor_id: "exec-1".to_string(), + url: "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc".to_string(), + } + ); + } + + #[test] + fn debug_output_redacts_bearer_token() { + let config = RemoteExecutorConfig::with_bearer_token( + "https://registry.example".to_string(), + "exec-1".to_string(), + "secret-token".to_string(), + ) + .expect("config"); + + let debug = format!("{config:?}"); + + assert!(debug.contains("")); + assert!(!debug.contains("secret-token")); + } +} diff --git a/codex-rs/exec-server/src/server.rs b/codex-rs/exec-server/src/server.rs index 62c1787381..bf33eb77ba 100644 --- a/codex-rs/exec-server/src/server.rs +++ b/codex-rs/exec-server/src/server.rs @@ -7,6 +7,7 @@ mod session_registry; mod transport; pub(crate) use handler::ExecServerHandler; +pub(crate) use processor::ConnectionProcessor; pub use transport::DEFAULT_LISTEN_URL; pub use transport::ExecServerListenUrlParseError; 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 87a9ff969a..479758f9a0 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 @@ -240,6 +240,7 @@ fn turn_completed_recovers_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), @@ -287,6 +288,7 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), @@ -335,6 +337,7 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, error: None, @@ -378,6 +381,7 @@ fn turn_failed_clears_stale_final_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, error: None, @@ -422,6 +426,7 @@ fn turn_interrupted_clears_stale_final_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Interrupted, error: 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 1641398ae6..045baacc7b 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -392,7 +392,7 @@ impl EventProcessorWithJsonOutput { pub fn thread_started_event(session_configured: &SessionConfiguredEvent) -> ThreadEvent { ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: session_configured.session_id.to_string(), + thread_id: session_configured.thread_id.to_string(), }) } 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 f83b54504a..88fd042f7c 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 @@ -32,6 +32,7 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, error: Some(codex_app_server_protocol::TurnError { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d61346f1d0..b035a19517 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -78,6 +78,8 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; +use codex_protocol::SessionId; +use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; @@ -154,6 +156,7 @@ use crate::cli::Command as ExecCommand; use crate::event_processor::EventProcessor; const DEFAULT_ANALYTICS_ENABLED: bool = true; +const EXEC_DEFAULT_LOG_FILTER: &str = "error,opentelemetry_sdk=off,opentelemetry_otlp=off"; enum InitialOperation { UserTurn { @@ -220,6 +223,14 @@ fn exec_root_span() -> tracing::Span { ) } +fn exec_stderr_env_filter() -> EnvFilter { + // OTEL export is best-effort; keep exporter self-diagnostics out of + // headless command output unless the caller opts in with RUST_LOG. + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(EXEC_DEFAULT_LOG_FILTER)) + .unwrap_or_else(|_| EnvFilter::new("error")) +} + pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { #[allow(clippy::print_stderr)] if let Some(message) = cli.removed_full_auto_warning() { @@ -266,18 +277,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result supports_color::on_cached(Stream::Stderr).is_some(), ), }; - // Build fmt layer (existing logging) to compose with OTEL layer. - let default_level = "error"; - - // Build env_filter separately and attach via with_filter. - let env_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(default_level)) - .unwrap_or_else(|_| EnvFilter::new(default_level)); - let fmt_layer = tracing_subscriber::fmt::layer() .with_ansi(stderr_with_ansi) .with_writer(std::io::stderr) - .with_filter(env_filter); + .with_filter(exec_stderr_env_filter()); let sandbox_mode = if removed_full_auto { Some(SandboxMode::WorkspaceWrite) @@ -694,7 +697,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let session_configured = session_configured_from_thread_resume_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) } else { let response: ThreadStartResponse = send_request_with_response( &client, @@ -709,7 +712,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) } } else { let response: ThreadStartResponse = send_request_with_response( @@ -724,7 +727,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { .map_err(anyhow::Error::msg)?; let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) }; let primary_thread_id_for_span = primary_thread_id.to_string(); @@ -1058,12 +1061,13 @@ fn session_configured_from_thread_start_response( config: &Config, ) -> Result { session_configured_from_thread_response( + &response.thread.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response @@ -1082,12 +1086,13 @@ fn session_configured_from_thread_resume_response( config: &Config, ) -> Result { session_configured_from_thread_response( + &response.thread.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response @@ -1115,12 +1120,13 @@ fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { reason = "session mapping keeps explicit fields" )] fn session_configured_from_thread_response( + session_id: &str, thread_id: &str, thread_name: Option, rollout_path: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, @@ -1128,12 +1134,16 @@ fn session_configured_from_thread_response( cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { - let session_id = codex_protocol::ThreadId::from_string(thread_id) + let session_id = SessionId::from_string(session_id) + .map_err(|err| format!("session id `{session_id}` is invalid: {err}"))?; + let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; Ok(SessionConfiguredEvent { session_id, + thread_id, forked_from_id: None, + thread_source: None, thread_name, model, model_provider_id, @@ -1144,8 +1154,6 @@ fn session_configured_from_thread_response( active_permission_profile, cwd, reasoning_effort, - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 648d512689..c12b483e89 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -8,6 +8,10 @@ use opentelemetry::trace::TraceId; use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::SdkTracerProvider; use pretty_assertions::assert_eq; +use std::io; +use std::io::Write; +use std::sync::Arc; +use std::sync::Mutex; use tempfile::tempdir; use tracing_opentelemetry::OpenTelemetrySpanExt; @@ -22,6 +26,61 @@ fn exec_defaults_analytics_to_enabled() { assert_eq!(DEFAULT_ANALYTICS_ENABLED, true); } +#[derive(Clone)] +struct TestLogWriter { + buffer: Arc>>, +} + +struct TestLogSink { + buffer: Arc>>, +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TestLogWriter { + type Writer = TestLogSink; + + fn make_writer(&'a self) -> Self::Writer { + TestLogSink { + buffer: Arc::clone(&self.buffer), + } + } +} + +impl Write for TestLogSink { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.lock().expect("log buffer lock").extend(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn exec_default_stderr_filter_suppresses_otel_self_diagnostics() { + let buffer = Arc::new(Mutex::new(Vec::new())); + let writer = TestLogWriter { + buffer: Arc::clone(&buffer), + }; + let subscriber = tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(writer) + .with_filter(EnvFilter::try_new(EXEC_DEFAULT_LOG_FILTER).expect("default filter")), + ); + + tracing::subscriber::with_default(subscriber, || { + tracing::error!(target: "opentelemetry_sdk", "telemetry export failed"); + tracing::error!(target: "opentelemetry_otlp", "telemetry request failed"); + tracing::error!(target: "codex_exec_test", "real exec error"); + }); + + let logs = String::from_utf8(buffer.lock().expect("log buffer lock").clone()).expect("utf8"); + assert!(!logs.contains("telemetry export failed")); + assert!(!logs.contains("telemetry request failed")); + assert!(logs.contains("real exec error")); +} + #[test] fn exec_root_span_can_be_parented_from_trace_context() { let subscriber = test_tracing_subscriber(); @@ -244,6 +303,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() { fn turn_items_for_thread_returns_matching_turn_items() { let thread = AppServerThread { id: "thread-1".to_string(), + session_id: "thread-1".to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -255,6 +315,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0-test".to_string(), source: codex_app_server_protocol::SessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -262,6 +323,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { turns: vec![ codex_app_server_protocol::Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::AgentMessage { id: "msg-1".to_string(), text: "hello".to_string(), @@ -276,6 +338,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::Plan { id: "plan-1".to_string(), text: "ship it".to_string(), @@ -308,6 +371,7 @@ fn should_backfill_turn_completed_items_skips_ephemeral_threads() { thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, @@ -437,6 +501,14 @@ async fn session_configured_from_thread_response_uses_review_policy_from_respons let event = session_configured_from_thread_start_response(&response, &config) .expect("build bootstrap session configured event"); + assert_eq!( + event.session_id.to_string(), + "67e55044-10b1-426f-9247-bb680e5fe0c7" + ); + assert_eq!( + event.thread_id.to_string(), + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ); assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview); } @@ -463,6 +535,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -474,6 +547,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: 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 5eda08c13d..efda58f412 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::models::WebSearchAction; @@ -104,10 +105,13 @@ fn map_todo_items_preserves_text_and_completion_state() { #[test] fn session_configured_produces_thread_started_event() { + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse"); let session_configured = SessionConfiguredEvent { - session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") - .expect("thread id should parse"), + session_id: SessionId::from(thread_id), + thread_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), @@ -118,8 +122,6 @@ fn session_configured_produces_thread_started_event() { active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path: None, @@ -142,6 +144,7 @@ fn turn_started_emits_turn_started_event() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::InProgress, error: None, @@ -1095,6 +1098,7 @@ fn plan_update_emits_started_then_updated_then_completed() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, error: None, @@ -1154,6 +1158,7 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, error: None, @@ -1236,6 +1241,7 @@ fn token_usage_update_is_emitted_on_turn_completion() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, error: None, @@ -1270,6 +1276,7 @@ fn turn_completion_recovers_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), @@ -1342,6 +1349,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::CommandExecution { id: "cmd-1".to_string(), command: "ls".to_string(), @@ -1409,6 +1417,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), text: "final answer".to_string(), @@ -1458,6 +1467,7 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, error: None, @@ -1506,6 +1516,7 @@ fn failed_turn_clears_stale_final_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, error: Some(TurnError { @@ -1533,6 +1544,7 @@ fn turn_completion_falls_back_to_final_plan_text() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Plan { id: "plan-1".to_string(), text: "ship the typed adapter".to_string(), @@ -1587,6 +1599,7 @@ fn turn_failure_prefers_structured_error_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, error: None, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index acb6fa195e..6c8ce49894 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -134,6 +134,8 @@ pub enum Feature { Sqlite, /// Enable startup memory extraction and file-backed memory consolidation. MemoryTool, + /// Enable product-owned built-in MCP servers. + BuiltInMcp, /// Enable the Chronicle sidecar for passive screen-context memories. Chronicle, /// Append additional AGENTS.md guidance to user instructions. @@ -204,6 +206,8 @@ pub enum Feature { CollaborationModes, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, + /// Prompt Codex Apps connector auth failures through MCP URL elicitations. + AuthElicitation, /// Enable personality selection in the TUI. Personality, /// Enable native artifact tools. @@ -227,6 +231,8 @@ pub enum Feature { ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. ResponsesWebsocketsV2, + /// Send `response.processed` over Responses API websockets after a turn response is recorded. + ResponsesWebsocketResponseProcessed, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Enable workspace dependency support. @@ -791,6 +797,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::BuiltInMcp, + key: "builtin_mcp", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Chronicle, key: "chronicle", @@ -1045,6 +1057,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::AuthElicitation, + key: "auth_elicitation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Personality, key: "personality", @@ -1123,6 +1141,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::ResponsesWebsocketResponseProcessed, + key: "responses_websocket_response_processed", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index da0e36442c..5464fa7a61 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -129,6 +129,29 @@ fn remote_compaction_v2_is_under_development() { ); } +#[test] +fn responses_websocket_response_processed_is_under_development() { + assert_eq!( + Feature::ResponsesWebsocketResponseProcessed.stage(), + Stage::UnderDevelopment + ); + assert_eq!( + Feature::ResponsesWebsocketResponseProcessed.default_enabled(), + false + ); + assert_eq!( + feature_for_key("responses_websocket_response_processed"), + Some(Feature::ResponsesWebsocketResponseProcessed) + ); +} + +#[test] +fn builtin_mcp_is_under_development() { + assert_eq!(Feature::BuiltInMcp.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::BuiltInMcp.default_enabled(), false); + assert_eq!(feature_for_key("builtin_mcp"), Some(Feature::BuiltInMcp)); +} + #[test] fn terminal_resize_reflow_is_experimental_and_enabled_by_default() { assert_eq!( @@ -247,6 +270,16 @@ fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); } +#[test] +fn auth_elicitation_is_under_development() { + assert_eq!(Feature::AuthElicitation.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::AuthElicitation.default_enabled(), false); + assert_eq!( + feature_for_key("auth_elicitation"), + Some(Feature::AuthElicitation) + ); +} + #[test] fn remote_control_is_under_development() { assert_eq!(Feature::RemoteControl.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/hooks/Cargo.toml b/codex-rs/hooks/Cargo.toml index 028a055424..1bd7f455f2 100644 --- a/codex-rs/hooks/Cargo.toml +++ b/codex-rs/hooks/Cargo.toml @@ -19,12 +19,15 @@ codex-config = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-output-truncation = { workspace = true } futures = { workspace = true, features = ["alloc"] } regex = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -tokio = { workspace = true, features = ["io-util", "process", "time"] } +tokio = { workspace = true, features = ["fs", "io-util", "process", "time"] } +tracing = { workspace = true } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index b9fa871504..359c068ee2 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::HashMap; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; @@ -6,21 +6,21 @@ use codex_config::ConfigLayerStackOrdering; use codex_config::HookStateToml; use codex_config::TomlValue; -/// Build hook enablement rules from config layers that are allowed to override +/// Build effective hook state from config layers that are allowed to override /// user preferences. /// /// This intentionally reads only user and session flag layers, including /// disabled layers, to match the skills config behavior. Project, managed, and -/// plugin layers can discover hooks, but they do not get to write user -/// enablement state. -pub(crate) fn disabled_hook_keys_from_stack( +/// plugin layers can discover hooks, but they do not get to write user hook +/// state. +pub(crate) fn hook_states_from_stack( config_layer_stack: Option<&ConfigLayerStack>, -) -> HashSet { +) -> HashMap { let Some(config_layer_stack) = config_layer_stack else { - return HashSet::new(); + return HashMap::new(); }; - let mut disabled_keys = HashSet::new(); + let mut states: HashMap = HashMap::new(); for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ true, @@ -54,21 +54,19 @@ pub(crate) fn disabled_hook_keys_from_stack( if key.is_empty() { continue; } - // Later layers win. Hooks without an explicit enabled override can - // still carry future per-hook state without changing enablement. - match state.enabled { - Some(false) => { - disabled_keys.insert(key.to_string()); - } - Some(true) => { - disabled_keys.remove(key); - } - None => {} + // Later layers win field-by-field so a future per-hook state write + // does not accidentally erase an existing enablement override. + let effective_state = states.entry(key.to_string()).or_default(); + if let Some(enabled) = state.enabled { + effective_state.enabled = Some(enabled); + } + if let Some(trusted_hash) = state.trusted_hash { + effective_state.trusted_hash = Some(trusted_hash); } } } - disabled_keys + states } #[cfg(test)] @@ -82,7 +80,7 @@ mod tests { use super::*; #[test] - fn disabled_hook_keys_from_stack_respects_layer_precedence() { + fn hook_states_from_stack_respects_layer_precedence() { let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; let stack = ConfigLayerStack::new( vec![ @@ -102,11 +100,65 @@ mod tests { ) .expect("config layer stack"); - assert_eq!(disabled_hook_keys_from_stack(Some(&stack)), HashSet::new()); + assert_eq!( + hook_states_from_stack(Some(&stack)), + HashMap::from([( + key.to_string(), + HookStateToml { + enabled: Some(true), + trusted_hash: None, + }, + )]) + ); } #[test] - fn disabled_hook_keys_from_stack_ignores_malformed_hook_events() { + fn hook_states_from_stack_merges_fields_across_layers() { + let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; + let stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/tmp/config.toml").abs(), + }, + config_with_hook_state( + key, + HookStateToml { + enabled: Some(/*enabled*/ false), + trusted_hash: None, + }, + ), + ), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + config_with_hook_state( + key, + HookStateToml { + enabled: None, + trusted_hash: Some("sha256:trusted".to_string()), + }, + ), + ), + ], + Default::default(), + Default::default(), + ) + .expect("config layer stack"); + + assert_eq!( + hook_states_from_stack(Some(&stack)), + HashMap::from([( + key.to_string(), + HookStateToml { + enabled: Some(false), + trusted_hash: Some("sha256:trusted".to_string()), + }, + )]) + ); + } + + #[test] + fn hook_states_from_stack_ignores_malformed_hook_events() { let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; let config: TomlValue = serde_json::from_value(serde_json::json!({ "hooks": { @@ -132,13 +184,19 @@ mod tests { .expect("config layer stack"); assert_eq!( - disabled_hook_keys_from_stack(Some(&stack)), - HashSet::from([key.to_string()]) + hook_states_from_stack(Some(&stack)), + HashMap::from([( + key.to_string(), + HookStateToml { + enabled: Some(false), + trusted_hash: None, + }, + )]) ); } #[test] - fn disabled_hook_keys_from_stack_ignores_malformed_state_entries() { + fn hook_states_from_stack_ignores_malformed_state_entries() { let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; let config: TomlValue = serde_json::from_value(serde_json::json!({ "hooks": { @@ -166,16 +224,29 @@ mod tests { .expect("config layer stack"); assert_eq!( - disabled_hook_keys_from_stack(Some(&stack)), - HashSet::from([key.to_string()]) + hook_states_from_stack(Some(&stack)), + HashMap::from([( + key.to_string(), + HookStateToml { + enabled: Some(false), + trusted_hash: None, + }, + )]) ); } fn config_with_hook_override(key: &str, enabled: Option) -> TomlValue { - let hook_state = match enabled { - Some(enabled) => serde_json::json!({ "enabled": enabled }), - None => serde_json::json!({}), - }; + config_with_hook_state( + key, + HookStateToml { + enabled, + trusted_hash: None, + }, + ) + } + + fn config_with_hook_state(key: &str, state: HookStateToml) -> TomlValue { + let hook_state = serde_json::to_value(state).expect("hook state should serialize"); serde_json::from_value(serde_json::json!({ "hooks": { "state": { diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 8c520b7497..f24da13bb4 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -8,23 +8,27 @@ use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::HookEventsToml; use codex_config::HookHandlerConfig; +use codex_config::HookStateToml; use codex_config::HooksFile; use codex_config::ManagedHooksRequirementsToml; use codex_config::MatcherGroup; use codex_config::RequirementSource; +use codex_config::TomlValue; +use codex_config::version_for_toml; use codex_plugin::PluginHookSource; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde::Serialize; use std::collections::HashMap; -use std::collections::HashSet; use super::ConfiguredHandler; use super::HookListEntry; -use crate::config_rules::disabled_hook_keys_from_stack; +use crate::config_rules::hook_states_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; +use codex_protocol::protocol::HookTrustStatus; pub(crate) struct DiscoveryResult { pub handlers: Vec, @@ -36,7 +40,8 @@ struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, key_source: String, source: HookSource, - disabled_hook_keys: &'a HashSet, + is_managed: bool, + hook_states: &'a HashMap, env: HashMap, plugin_id: Option, } @@ -50,7 +55,7 @@ pub(crate) fn discover_handlers( let mut hook_entries = Vec::new(); let mut warnings = plugin_hook_load_warnings; let mut display_order = 0_i64; - let disabled_hook_keys = disabled_hook_keys_from_stack(config_layer_stack); + let hook_states = hook_states_from_stack(config_layer_stack); if let Some(config_layer_stack) = config_layer_stack { append_managed_requirement_handlers( @@ -59,14 +64,14 @@ pub(crate) fn discover_handlers( &mut warnings, &mut display_order, config_layer_stack, - &disabled_hook_keys, + &hook_states, ); for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ) { - let hook_source = hook_source_for_config_layer_source(&layer.name); + let (hook_source, is_managed) = hook_metadata_for_config_layer_source(&layer.name); let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); @@ -92,7 +97,8 @@ pub(crate) fn discover_handlers( path: &source_path, key_source: source_path.display().to_string(), source: hook_source, - disabled_hook_keys: &disabled_hook_keys, + is_managed, + hook_states: &hook_states, env: HashMap::new(), plugin_id: None, }, @@ -108,7 +114,7 @@ pub(crate) fn discover_handlers( &mut warnings, &mut display_order, plugin_hook_sources, - &disabled_hook_keys, + &hook_states, ); DiscoveryResult { @@ -124,7 +130,7 @@ fn append_managed_requirement_handlers( warnings: &mut Vec, display_order: &mut i64, config_layer_stack: &ConfigLayerStack, - disabled_hook_keys: &HashSet, + hook_states: &HashMap, ) { let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else { return; @@ -143,7 +149,8 @@ fn append_managed_requirement_handlers( path: &source_path, key_source: source_path.display().to_string(), source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), - disabled_hook_keys, + is_managed: true, + hook_states, env: HashMap::new(), plugin_id: None, }, @@ -157,9 +164,8 @@ fn append_plugin_hook_sources( warnings: &mut Vec, display_order: &mut i64, plugin_hook_sources: Vec, - disabled_hook_keys: &HashSet, + hook_states: &HashMap, ) { - // TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable. for source in plugin_hook_sources { let PluginHookSource { plugin_root, @@ -188,7 +194,8 @@ fn append_plugin_hook_sources( path: &source_path, key_source: format!("{plugin_id}:{source_relative_path}"), source: HookSource::Plugin, - disabled_hook_keys, + is_managed: false, + hook_states, env, plugin_id: Some(plugin_id), }, @@ -374,7 +381,7 @@ fn append_matcher_groups( )); continue; } - for (handler_index, handler) in group.hooks.into_iter().enumerate() { + for (handler_index, handler) in group.hooks.iter().cloned().enumerate() { match handler { HookHandlerConfig::Command { command, @@ -396,10 +403,18 @@ fn append_matcher_groups( )); continue; } + let timeout_sec = timeout_sec.unwrap_or(600).max(1); + let normalized_handler = HookHandlerConfig::Command { + command: command.clone(), + timeout_sec: Some(timeout_sec), + r#async, + status_message: status_message.clone(), + }; + let current_hash = + command_hook_hash(event_name, matcher, &group, normalized_handler); let command = source.env.iter().fold(command, |command, (key, value)| { command.replace(&format!("${{{key}}}"), value) }); - let timeout_sec = timeout_sec.unwrap_or(600).max(1); // TODO(abhinav): replace this positional suffix with a durable hook id. let key = format!( "{}:{}:{}:{}", @@ -408,8 +423,11 @@ fn append_matcher_groups( group_index, handler_index ); - let enabled = - source.source.is_managed() || !source.disabled_hook_keys.contains(&key); + let state = source.hook_states.get(&key); + let enabled = hook_enabled(source.is_managed, state); + let trusted_hash = hook_trusted_hash(source.is_managed, state); + let trust_status = + hook_trust_status(source.is_managed, ¤t_hash, trusted_hash); hook_entries.push(HookListEntry { key, event_name, @@ -423,9 +441,16 @@ fn append_matcher_groups( plugin_id: source.plugin_id.clone(), display_order: *display_order, enabled, - is_managed: source.source.is_managed(), + is_managed: source.is_managed, + current_hash, + trust_status, }); - if enabled { + if enabled + && matches!( + trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + ) + { handlers.push(ConfiguredHandler { event_name, matcher: matcher.map(ToOwned::to_owned), @@ -453,6 +478,34 @@ fn append_matcher_groups( } } +/// Hash a normalized, config-derived identity instead of source text so equivalent +/// hooks from config TOML and hooks.json converge on the same trust identity. +#[derive(Serialize)] +struct NormalizedHookIdentity { + event_name: &'static str, + #[serde(flatten)] + group: MatcherGroup, +} + +fn command_hook_hash( + event_name: codex_protocol::protocol::HookEventName, + matcher: Option<&str>, + group: &MatcherGroup, + normalized_handler: HookHandlerConfig, +) -> String { + let mut group = group.clone(); + group.matcher = matcher.map(ToOwned::to_owned); + group.hooks = vec![normalized_handler]; + let identity = NormalizedHookIdentity { + event_name: hook_event_key_label(event_name), + group, + }; + let Ok(value) = TomlValue::try_from(identity) else { + unreachable!("normalized hook identity should serialize to TOML"); + }; + version_for_toml(&value) +} + fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { match event_name { codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use", @@ -464,17 +517,45 @@ fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> } } -fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { - match source { - ConfigLayerSource::System { .. } => HookSource::System, - ConfigLayerSource::User { .. } => HookSource::User, - ConfigLayerSource::Project { .. } => HookSource::Project, - ConfigLayerSource::Mdm { .. } => HookSource::Mdm, - ConfigLayerSource::SessionFlags => HookSource::SessionFlags, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => { - HookSource::LegacyManagedConfigFile +fn hook_trust_status( + is_managed: bool, + current_hash: &str, + trusted_hash: Option<&str>, +) -> HookTrustStatus { + if is_managed { + HookTrustStatus::Managed + } else { + match trusted_hash { + Some(trusted_hash) if trusted_hash == current_hash => HookTrustStatus::Trusted, + Some(_) => HookTrustStatus::Modified, + None => HookTrustStatus::Untrusted, + } + } +} + +fn hook_enabled(is_managed: bool, state: Option<&HookStateToml>) -> bool { + is_managed || state.and_then(|state| state.enabled) != Some(false) +} + +fn hook_trusted_hash(is_managed: bool, state: Option<&HookStateToml>) -> Option<&str> { + (!is_managed) + .then(|| state.and_then(|state| state.trusted_hash.as_deref())) + .flatten() +} + +fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSource, bool) { + match source { + ConfigLayerSource::System { .. } => (HookSource::System, true), + ConfigLayerSource::User { .. } => (HookSource::User, false), + ConfigLayerSource::Project { .. } => (HookSource::Project, false), + ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true), + ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false), + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => { + (HookSource::LegacyManagedConfigFile, true) + } + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { + (HookSource::LegacyManagedConfigMdm, true) } - ConfigLayerSource::LegacyManagedConfigTomlFromMdm => HookSource::LegacyManagedConfigMdm, } } @@ -508,6 +589,7 @@ mod tests { use super::ConfiguredHandler; use super::append_matcher_groups; use codex_config::HookHandlerConfig; + use codex_config::HookStateToml; use codex_config::MatcherGroup; use codex_config::TomlValue; @@ -516,18 +598,19 @@ mod tests { } fn hook_source() -> HookSource { - HookSource::User + HookSource::System } fn hook_handler_source<'a>( path: &'a AbsolutePathBuf, - disabled_hook_keys: &'a std::collections::HashSet, + hook_states: &'a std::collections::HashMap, ) -> super::HookHandlerSource<'a> { super::HookHandlerSource { path, key_source: path.display().to_string(), source: hook_source(), - disabled_hook_keys, + is_managed: true, + hook_states, env: std::collections::HashMap::new(), plugin_id: None, } @@ -551,14 +634,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let disabled_hook_keys = std::collections::HashSet::new(); + let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - &hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &hook_states), HookEventName::UserPromptSubmit, vec![command_group(Some("["))], ); @@ -586,14 +669,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let disabled_hook_keys = std::collections::HashSet::new(); + let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - &hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &hook_states), HookEventName::PreToolUse, vec![command_group(Some("^Bash$"))], ); @@ -621,14 +704,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let disabled_hook_keys = std::collections::HashSet::new(); + let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - &hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &hook_states), HookEventName::PreToolUse, vec![command_group(Some("*"))], ); @@ -644,14 +727,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let disabled_hook_keys = std::collections::HashSet::new(); + let hook_states = std::collections::HashMap::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - &hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &hook_states), HookEventName::PostToolUse, vec![command_group(Some("Edit|Write"))], ); @@ -713,50 +796,50 @@ mod tests { } #[test] - fn hook_source_for_config_layer_source_discards_source_details() { + fn hook_metadata_for_config_layer_source_discards_source_details() { let config_file = test_path_buf("/tmp/.codex/config.toml").abs(); let dot_codex_folder = test_path_buf("/tmp/worktree/.codex").abs(); assert_eq!( - super::hook_source_for_config_layer_source(&ConfigLayerSource::System { + super::hook_metadata_for_config_layer_source(&ConfigLayerSource::System { file: config_file.clone(), }), - HookSource::System, + (HookSource::System, true), ); assert_eq!( - super::hook_source_for_config_layer_source(&ConfigLayerSource::User { + super::hook_metadata_for_config_layer_source(&ConfigLayerSource::User { file: config_file.clone(), }), - HookSource::User, + (HookSource::User, false), ); assert_eq!( - super::hook_source_for_config_layer_source(&ConfigLayerSource::Project { + super::hook_metadata_for_config_layer_source(&ConfigLayerSource::Project { dot_codex_folder }), - HookSource::Project, + (HookSource::Project, false), ); assert_eq!( - super::hook_source_for_config_layer_source(&ConfigLayerSource::Mdm { + super::hook_metadata_for_config_layer_source(&ConfigLayerSource::Mdm { domain: "com.openai.codex".to_string(), key: "config".to_string(), }), - HookSource::Mdm, + (HookSource::Mdm, true), ); assert_eq!( - super::hook_source_for_config_layer_source(&ConfigLayerSource::SessionFlags), - HookSource::SessionFlags, + super::hook_metadata_for_config_layer_source(&ConfigLayerSource::SessionFlags), + (HookSource::SessionFlags, false), ); assert_eq!( - super::hook_source_for_config_layer_source( + super::hook_metadata_for_config_layer_source( &ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config_file }, ), - HookSource::LegacyManagedConfigFile, + (HookSource::LegacyManagedConfigFile, true), ); assert_eq!( - super::hook_source_for_config_layer_source( + super::hook_metadata_for_config_layer_source( &ConfigLayerSource::LegacyManagedConfigTomlFromMdm, ), - HookSource::LegacyManagedConfigMdm, + (HookSource::LegacyManagedConfigMdm, true), ); } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index c06c9fabd7..18262989e8 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -6,14 +6,6 @@ pub(crate) mod schema_loader; use std::collections::HashMap; -use codex_config::ConfigLayerStack; -use codex_plugin::PluginHookSource; -use codex_protocol::protocol::HookEventName; -use codex_protocol::protocol::HookHandlerType; -use codex_protocol::protocol::HookRunSummary; -use codex_protocol::protocol::HookSource; -use codex_utils_absolute_path::AbsolutePathBuf; - use crate::events::permission_request::PermissionRequestOutcome; use crate::events::permission_request::PermissionRequestRequest; use crate::events::post_tool_use::PostToolUseOutcome; @@ -26,6 +18,16 @@ use crate::events::stop::StopOutcome; use crate::events::stop::StopRequest; use crate::events::user_prompt_submit::UserPromptSubmitOutcome; use crate::events::user_prompt_submit::UserPromptSubmitRequest; +use crate::output_spill::HookOutputSpiller; +use codex_config::ConfigLayerStack; +use codex_plugin::PluginHookSource; +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookHandlerType; +use codex_protocol::protocol::HookRunSummary; +use codex_protocol::protocol::HookSource; +use codex_protocol::protocol::HookTrustStatus; +use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Debug, Clone)] pub(crate) struct CommandShell { @@ -83,6 +85,8 @@ pub struct HookListEntry { pub display_order: i64, pub enabled: bool, pub is_managed: bool, + pub current_hash: String, + pub trust_status: HookTrustStatus, } #[derive(Clone)] @@ -90,6 +94,7 @@ pub(crate) struct ClaudeHooksEngine { handlers: Vec, warnings: Vec, shell: CommandShell, + output_spiller: HookOutputSpiller, } impl ClaudeHooksEngine { @@ -105,6 +110,7 @@ impl ClaudeHooksEngine { handlers: Vec::new(), warnings: Vec::new(), shell, + output_spiller: HookOutputSpiller::new(), }; } @@ -118,6 +124,7 @@ impl ClaudeHooksEngine { handlers: discovered.handlers, warnings: discovered.warnings, shell, + output_spiller: HookOutputSpiller::new(), } } @@ -155,7 +162,13 @@ impl ClaudeHooksEngine { request: SessionStartRequest, turn_id: Option, ) -> SessionStartOutcome { - crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await + let session_id = request.session_id; + let mut outcome = + crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await; + outcome.additional_contexts = self + .maybe_spill_texts(session_id, outcome.additional_contexts) + .await; + outcome } pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { @@ -173,7 +186,16 @@ impl ClaudeHooksEngine { &self, request: PostToolUseRequest, ) -> PostToolUseOutcome { - crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await + let session_id = request.session_id; + let mut outcome = + crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await; + outcome.additional_contexts = self + .maybe_spill_texts(session_id, outcome.additional_contexts) + .await; + outcome.feedback_message = self + .maybe_spill_text(session_id, outcome.feedback_message) + .await; + outcome } pub(crate) fn preview_user_prompt_submit( @@ -187,7 +209,13 @@ impl ClaudeHooksEngine { &self, request: UserPromptSubmitRequest, ) -> UserPromptSubmitOutcome { - crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await + let session_id = request.session_id; + let mut outcome = + crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await; + outcome.additional_contexts = self + .maybe_spill_texts(session_id, outcome.additional_contexts) + .await; + outcome } pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec { @@ -195,7 +223,35 @@ impl ClaudeHooksEngine { } pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome { - crate::events::stop::run(&self.handlers, &self.shell, request).await + let session_id = request.session_id; + let mut outcome = crate::events::stop::run(&self.handlers, &self.shell, request).await; + outcome.continuation_fragments = self + .maybe_spill_prompt_fragments(session_id, outcome.continuation_fragments) + .await; + outcome + } + + async fn maybe_spill_texts(&self, session_id: ThreadId, texts: Vec) -> Vec { + self.output_spiller + .maybe_spill_texts(session_id, texts) + .await + } + + async fn maybe_spill_text(&self, session_id: ThreadId, text: Option) -> Option { + match text { + Some(text) => Some(self.output_spiller.maybe_spill_text(session_id, text).await), + None => None, + } + } + + async fn maybe_spill_prompt_fragments( + &self, + session_id: ThreadId, + fragments: Vec, + ) -> Vec { + self.output_spiller + .maybe_spill_prompt_fragments(session_id, fragments) + .await } } diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index c37539bb1d..32739165f1 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -22,6 +22,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; +use codex_protocol::protocol::HookTrustStatus; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -121,7 +122,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); - assert!(engine.handlers[0].source.is_managed()); + assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, @@ -168,6 +169,68 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\"")); } +#[test] +fn unknown_requirement_source_hooks_stay_managed() { + let temp = tempdir().expect("create temp dir"); + let managed_dir = + AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); + fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); + let managed_hooks = managed_hooks_for_current_platform( + managed_dir, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/managed.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + }, + ); + let config_layer_stack = ConfigLayerStack::new( + Vec::new(), + ConfigRequirements { + managed_hooks: Some(ConstrainedWithSource::new( + Constrained::allow_any(managed_hooks.clone()), + Some(RequirementSource::Unknown), + )), + ..ConfigRequirements::default() + }, + ConfigRequirementsToml { + hooks: Some(managed_hooks), + ..ConfigRequirementsToml::default() + }, + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert_eq!(engine.handlers.len(), 1); + assert_eq!(engine.handlers[0].source, HookSource::Unknown); + let discovered = + super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + assert_eq!(discovered.hook_entries.len(), 1); + assert_eq!(discovered.hook_entries[0].source, HookSource::Unknown); + assert_eq!(discovered.hook_entries[0].enabled, true); + assert_eq!(discovered.hook_entries[0].is_managed, true); + assert_eq!( + discovered.hook_entries[0].trust_status, + HookTrustStatus::Managed + ); +} + #[test] fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let temp = tempdir().expect("create temp dir"); @@ -228,13 +291,17 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert!(engine.handlers[0].source.is_managed()); + assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); let discovered = super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 2); assert_eq!(discovered.hook_entries[0].key, managed_disabled_key); assert_eq!(discovered.hook_entries[0].enabled, true); assert!(discovered.hook_entries[0].is_managed); + assert_eq!( + discovered.hook_entries[0].trust_status, + HookTrustStatus::Managed + ); assert_eq!(discovered.hook_entries[1].key, user_disabled_key); assert_eq!(discovered.hook_entries[1].enabled, false); assert!(!discovered.hook_entries[1].is_managed); @@ -281,13 +348,20 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert!(engine.handlers[0].source.is_managed()); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigFile + ); let discovered = super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].key, managed_key); assert_eq!(discovered.hook_entries[0].enabled, true); assert!(discovered.hook_entries[0].is_managed); + assert_eq!( + discovered.hook_entries[0].trust_status, + HookTrustStatus::Managed + ); } fn config_with_hook_state(key: &str, enabled: bool) -> TomlValue { @@ -339,6 +413,45 @@ fn config_with_pre_tool_use_hook(command: &str) -> TomlValue { .expect("config TOML should deserialize") } +fn trusted_plugin_hook_stack( + config_path: AbsolutePathBuf, + plugin_hook_sources: &[PluginHookSource], +) -> ConfigLayerStack { + let discovered = super::discovery::discover_handlers( + /*config_layer_stack*/ None, + plugin_hook_sources.to_vec(), + Vec::new(), + ); + let state = discovered + .hook_entries + .into_iter() + .map(|entry| { + ( + entry.key, + serde_json::json!({ + "trusted_hash": entry.current_hash, + }), + ) + }) + .collect::>(); + let config = serde_json::from_value(serde_json::json!({ + "hooks": { + "state": state, + }, + })) + .expect("config TOML should deserialize"); + + ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + config, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack") +} + #[test] fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { let temp = tempdir().expect("create temp dir"); @@ -473,7 +586,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { config_table.insert("hooks".to_string(), hooks_table); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( - ConfigLayerSource::User { + ConfigLayerSource::System { file: config_path.clone(), }, config_toml, @@ -514,11 +627,13 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 2); - assert!( + assert_eq!( engine .handlers .iter() - .all(|handler| !handler.source.is_managed()) + .map(|handler| handler.source) + .collect::>(), + vec![HookSource::System, HookSource::System] ); assert_eq!(preview[0].source_path, hooks_json_path); assert_eq!(preview[1].source_path, config_path); @@ -567,9 +682,13 @@ print(json.dumps({ ..Default::default() }, }]; + let config_layer_stack = trusted_plugin_hook_stack( + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"), + &plugin_hook_sources, + ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, - /*config_layer_stack*/ None, + Some(&config_layer_stack), plugin_hook_sources.clone(), Vec::new(), CommandShell { @@ -671,9 +790,13 @@ fn plugin_hook_sources_expand_plugin_placeholders() { ..Default::default() }, }]; + let config_layer_stack = trusted_plugin_hook_stack( + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"), + &plugin_hook_sources, + ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, - /*config_layer_stack*/ None, + Some(&config_layer_stack), plugin_hook_sources, Vec::new(), CommandShell { diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index 0a3a994e19..464c7f8608 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -16,6 +16,7 @@ pub(crate) struct SessionStartOutput { pub(crate) struct PreToolUseOutput { pub universal: UniversalOutput, pub block_reason: Option, + pub additional_context: Option, pub invalid_reason: Option, } @@ -92,11 +93,12 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { } = parse_json(stdout)?; let universal = UniversalOutput::from(universal_wire); let hook_specific_output = hook_specific_output.as_ref(); + let additional_context = + hook_specific_output.and_then(|output| output.additional_context.clone()); let use_hook_specific_decision = hook_specific_output.is_some_and(|output| { output.permission_decision.is_some() || output.permission_decision_reason.is_some() || output.updated_input.is_some() - || output.additional_context.is_some() }); let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| { if use_hook_specific_decision { @@ -127,6 +129,7 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { Some(PreToolUseOutput { universal, block_reason, + additional_context, invalid_reason, }) } @@ -339,13 +342,6 @@ fn unsupported_pre_tool_use_hook_specific_output( ) -> Option { if output.updated_input.is_some() { Some("PreToolUse hook returned unsupported updatedInput".to_string()) - } else if output - .additional_context - .as_deref() - .and_then(trimmed_reason) - .is_some() - { - Some("PreToolUse hook returned unsupported additionalContext".to_string()) } else { match output.permission_decision { Some(PreToolUsePermissionDecisionWire::Allow) => { diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 6fe1555229..39d0e61258 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -37,12 +37,14 @@ pub struct PreToolUseOutcome { pub hook_events: Vec, pub should_block: bool, pub block_reason: Option, + pub additional_contexts: Vec, } #[derive(Debug, Default, PartialEq, Eq)] struct PreToolUseHandlerData { should_block: bool, block_reason: Option, + additional_contexts_for_model: Vec, } pub(crate) fn preview( @@ -78,6 +80,7 @@ pub(crate) async fn run( hook_events: Vec::new(), should_block: false, block_reason: None, + additional_contexts: Vec::new(), }; } @@ -108,6 +111,11 @@ pub(crate) async fn run( let block_reason = results .iter() .find_map(|result| result.data.block_reason.clone()); + let additional_contexts = common::flatten_additional_contexts( + results + .iter() + .map(|result| result.data.additional_contexts_for_model.as_slice()), + ); PreToolUseOutcome { hook_events: results @@ -118,6 +126,7 @@ pub(crate) async fn run( .collect(), should_block, block_reason, + additional_contexts, } } @@ -151,6 +160,7 @@ fn parse_completed( let mut status = HookRunStatus::Completed; let mut should_block = false; let mut block_reason = None; + let mut additional_contexts_for_model = Vec::new(); match run_result.error.as_deref() { Some(error) => { @@ -177,14 +187,23 @@ fn parse_completed( kind: HookOutputEntryKind::Error, text: invalid_reason, }); - } else if let Some(reason) = parsed.block_reason { - status = HookRunStatus::Blocked; - should_block = true; - block_reason = Some(reason.clone()); - entries.push(HookOutputEntry { - kind: HookOutputEntryKind::Feedback, - text: reason, - }); + } else { + if let Some(additional_context) = parsed.additional_context { + common::append_additional_context( + &mut entries, + &mut additional_contexts_for_model, + additional_context, + ); + } + if let Some(reason) = parsed.block_reason { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } } } else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') { status = HookRunStatus::Failed; @@ -238,6 +257,7 @@ fn parse_completed( data: PreToolUseHandlerData { should_block, block_reason, + additional_contexts_for_model, }, } } @@ -247,6 +267,7 @@ fn serialization_failure_outcome(hook_events: Vec) -> PreToo hook_events, should_block: false, block_reason: None, + additional_contexts: Vec::new(), } } @@ -298,6 +319,7 @@ mod tests { PreToolUseHandlerData { should_block: true, block_reason: Some("do not run that".to_string()), + additional_contexts_for_model: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -327,6 +349,7 @@ mod tests { PreToolUseHandlerData { should_block: true, block_reason: Some("do not run that".to_string()), + additional_contexts_for_model: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); @@ -339,6 +362,42 @@ mod tests { ); } + #[test] + fn deprecated_block_decision_with_additional_context_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"decision":"block","reason":"do not run that","hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"remember this"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + additional_contexts_for_model: vec!["remember this".to_string()], + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![ + HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "remember this".to_string(), + }, + HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }, + ] + ); + } + #[test] fn unsupported_permission_decision_fails_open() { let parsed = parse_completed( @@ -356,6 +415,7 @@ mod tests { PreToolUseHandlerData { should_block: false, block_reason: None, + additional_contexts_for_model: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); @@ -381,6 +441,7 @@ mod tests { PreToolUseHandlerData { should_block: false, block_reason: None, + additional_contexts_for_model: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); @@ -394,7 +455,7 @@ mod tests { } #[test] - fn unsupported_additional_context_fails_open() { + fn additional_context_is_recorded() { let parsed = parse_completed( &handler(), run_result( @@ -408,17 +469,24 @@ mod tests { assert_eq!( parsed.data, PreToolUseHandlerData { - should_block: false, - block_reason: None, + should_block: true, + block_reason: Some("do not run that".to_string()), + additional_contexts_for_model: vec!["nope".to_string()], } ); - assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); assert_eq!( parsed.completed.run.entries, - vec![HookOutputEntry { - kind: HookOutputEntryKind::Error, - text: "PreToolUse hook returned unsupported additionalContext".to_string(), - }] + vec![ + HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "nope".to_string(), + }, + HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }, + ] ); } @@ -435,6 +503,7 @@ mod tests { PreToolUseHandlerData { should_block: false, block_reason: None, + additional_contexts_for_model: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); @@ -454,6 +523,7 @@ mod tests { PreToolUseHandlerData { should_block: false, block_reason: None, + additional_contexts_for_model: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); @@ -479,6 +549,7 @@ mod tests { PreToolUseHandlerData { should_block: true, block_reason: Some("blocked by policy".to_string()), + additional_contexts_for_model: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 4e16969a58..ad627b497a 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -2,6 +2,7 @@ mod config_rules; mod engine; pub(crate) mod events; mod legacy_notify; +mod output_spill; mod registry; mod schema; mod types; diff --git a/codex-rs/hooks/src/output_spill.rs b/codex-rs/hooks/src/output_spill.rs new file mode 100644 index 0000000000..b1828c0825 --- /dev/null +++ b/codex-rs/hooks/src/output_spill.rs @@ -0,0 +1,111 @@ +use codex_protocol::ThreadId; +use codex_protocol::items::HookPromptFragment; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::formatted_truncate_text; +use tokio::fs; +use tracing::warn; +use uuid::Uuid; + +const HOOK_OUTPUTS_DIR: &str = "hook_outputs"; +const HOOK_OUTPUT_TOKEN_LIMIT: usize = 2_500; + +#[derive(Clone)] +pub(crate) struct HookOutputSpiller { + output_dir: AbsolutePathBuf, +} + +impl HookOutputSpiller { + pub(crate) fn new() -> Self { + Self { + output_dir: AbsolutePathBuf::resolve_path_against_base(std::env::temp_dir(), "/") + .join(HOOK_OUTPUTS_DIR), + } + } + + /// Keeps hook text within the model-visible hook-output budget. + /// + /// Oversized text is written in full under the OS temp directory at + /// `/hook_outputs//` + /// and replaced with the same head/tail preview style used for other truncated + /// output, plus a path back to the preserved full text. + pub(crate) async fn maybe_spill_text(&self, thread_id: ThreadId, text: String) -> String { + if approx_token_count(&text) <= HOOK_OUTPUT_TOKEN_LIMIT { + return text; + } + + let path = hook_output_path(&self.output_dir, thread_id); + if let Some(parent) = path.parent() + && let Err(err) = fs::create_dir_all(parent.as_ref()).await + { + warn!( + "failed to create hook output directory {}: {err}", + parent.display() + ); + return formatted_truncate_text( + &text, + TruncationPolicy::Tokens(HOOK_OUTPUT_TOKEN_LIMIT), + ); + } + + if let Err(err) = fs::write(path.as_ref(), &text).await { + warn!("failed to write hook output {}: {err}", path.display()); + return formatted_truncate_text( + &text, + TruncationPolicy::Tokens(HOOK_OUTPUT_TOKEN_LIMIT), + ); + } + + spilled_hook_output_preview(&text, &path) + } + + pub(crate) async fn maybe_spill_texts( + &self, + thread_id: ThreadId, + texts: Vec, + ) -> Vec { + let mut spilled = Vec::with_capacity(texts.len()); + for text in texts { + spilled.push(self.maybe_spill_text(thread_id, text).await); + } + spilled + } + + pub(crate) async fn maybe_spill_prompt_fragments( + &self, + thread_id: ThreadId, + fragments: Vec, + ) -> Vec { + let mut spilled = Vec::with_capacity(fragments.len()); + for fragment in fragments { + spilled.push(HookPromptFragment { + text: self.maybe_spill_text(thread_id, fragment.text).await, + hook_run_id: fragment.hook_run_id, + }); + } + spilled + } +} + +fn hook_output_path(output_dir: &AbsolutePathBuf, thread_id: ThreadId) -> AbsolutePathBuf { + output_dir + .join(thread_id.to_string()) + .join(format!("{}.txt", Uuid::new_v4())) +} + +/// Builds the model-visible replacement for a spilled hook output. +/// +/// The path footer is budgeted before truncation so adding the recovery path +/// does not let the preview grow past the hook-output limit. +fn spilled_hook_output_preview(text: &str, path: &AbsolutePathBuf) -> String { + let footer = format!("\n\nFull hook output saved to: {}", path.display()); + let preview_policy = TruncationPolicy::Tokens( + HOOK_OUTPUT_TOKEN_LIMIT.saturating_sub(approx_token_count(&footer)), + ); + format!("{}{footer}", formatted_truncate_text(text, preview_policy)) +} + +#[cfg(test)] +#[path = "output_spill_tests.rs"] +mod tests; diff --git a/codex-rs/hooks/src/output_spill_tests.rs b/codex-rs/hooks/src/output_spill_tests.rs new file mode 100644 index 0000000000..6c5f9b5848 --- /dev/null +++ b/codex-rs/hooks/src/output_spill_tests.rs @@ -0,0 +1,42 @@ +use super::*; +use anyhow::Context; +use anyhow::Result; +use tempfile::tempdir; + +#[tokio::test] +async fn small_hook_output_remains_inline() -> Result<()> { + let dir = tempdir()?; + let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR); + let thread_id = ThreadId::new(); + let spiller = HookOutputSpiller { + output_dir: output_dir.clone(), + }; + + let output = spiller + .maybe_spill_text(thread_id, "short".to_string()) + .await; + + assert_eq!(output, "short"); + assert!(!output_dir.exists()); + Ok(()) +} + +#[tokio::test] +async fn large_hook_output_spills_to_file() -> Result<()> { + let dir = tempdir()?; + let text = "hook output ".repeat(1_000); + let output_dir = AbsolutePathBuf::from_absolute_path(dir.path())?.join(HOOK_OUTPUTS_DIR); + let spiller = HookOutputSpiller { output_dir }; + + let output = spiller + .maybe_spill_text(ThreadId::new(), text.clone()) + .await; + + assert!(output.contains("tokens truncated")); + let path = output + .lines() + .find_map(|line| line.strip_prefix("Full hook output saved to: ")) + .context("spill path")?; + assert_eq!(fs::read_to_string(path).await?, text); + Ok(()) +} diff --git a/codex-rs/hooks/src/user_notification.rs b/codex-rs/hooks/src/user_notification.rs new file mode 100644 index 0000000000..97af09a3b9 --- /dev/null +++ b/codex-rs/hooks/src/user_notification.rs @@ -0,0 +1,153 @@ +use std::process::Stdio; +use std::sync::Arc; + +use serde::Serialize; + +use crate::Hook; +use crate::HookEvent; +use crate::HookPayload; +use crate::HookResult; +use crate::command_from_argv; + +/// Legacy notify payload appended as the final argv argument for backward compatibility. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +enum UserNotification { + #[serde(rename_all = "kebab-case")] + AgentTurnComplete { + thread_id: String, + turn_id: String, + cwd: String, + #[serde(skip_serializing_if = "Option::is_none")] + client: Option, + + /// Messages that the user sent to the agent to initiate the turn. + input_messages: Vec, + + /// The last message sent by the assistant in the turn. + last_assistant_message: Option, + }, +} + +pub fn legacy_notify_json(payload: &HookPayload) -> Result { + match &payload.hook_event { + HookEvent::AfterAgent { event } => { + serde_json::to_string(&UserNotification::AgentTurnComplete { + thread_id: event.thread_id.to_string(), + turn_id: event.turn_id.clone(), + cwd: payload.cwd.display().to_string(), + client: payload.client.clone(), + input_messages: event.input_messages.clone(), + last_assistant_message: event.last_assistant_message.clone(), + }) + } + _ => Err(serde_json::Error::io(std::io::Error::other( + "legacy notify payload is only supported for after_agent", + ))), + } +} + +pub fn notify_hook(argv: Vec) -> Hook { + let argv = Arc::new(argv); + Hook { + name: "legacy_notify".to_string(), + func: Arc::new(move |payload: &HookPayload| { + let argv = Arc::clone(&argv); + Box::pin(async move { + let mut command = match command_from_argv(&argv) { + Some(command) => command, + None => return HookResult::Success, + }; + if let Ok(notify_payload) = legacy_notify_json(payload) { + command.arg(notify_payload); + } + + // Backwards-compat: match legacy notify behavior (argv + JSON arg, fire-and-forget). + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + match command.spawn() { + Ok(_) => HookResult::Success, + Err(err) => HookResult::FailedContinue(err.into()), + } + }) + }), + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use codex_protocol::ThreadId; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + use serde_json::Value; + use serde_json::json; + + use super::*; + + fn expected_notification_json() -> Value { + let cwd = test_path_buf("/Users/example/project"); + json!({ + "type": "agent-turn-complete", + "thread-id": "b5f6c1c2-1111-2222-3333-444455556666", + "turn-id": "12345", + "cwd": cwd.display().to_string(), + "client": "codex-tui", + "input-messages": ["Rename `foo` to `bar` and update the callsites."], + "last-assistant-message": "Rename complete and verified `cargo build` succeeds.", + }) + } + + #[test] + fn test_user_notification() -> Result<()> { + let notification = UserNotification::AgentTurnComplete { + thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(), + turn_id: "12345".to_string(), + cwd: test_path_buf("/Users/example/project") + .display() + .to_string(), + client: Some("codex-tui".to_string()), + input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()], + last_assistant_message: Some( + "Rename complete and verified `cargo build` succeeds.".to_string(), + ), + }; + let serialized = serde_json::to_string(¬ification)?; + let actual: Value = serde_json::from_str(&serialized)?; + assert_eq!(actual, expected_notification_json()); + Ok(()) + } + + #[test] + fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> { + let payload = HookPayload { + session_id: ThreadId::new(), + cwd: test_path_buf("/Users/example/project").abs(), + client: Some("codex-tui".to_string()), + triggered_at: chrono::Utc::now(), + hook_event: HookEvent::AfterAgent { + event: crate::HookEventAfterAgent { + thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666") + .expect("valid thread id"), + turn_id: "12345".to_string(), + input_messages: vec![ + "Rename `foo` to `bar` and update the callsites.".to_string(), + ], + last_assistant_message: Some( + "Rename complete and verified `cargo build` succeeds.".to_string(), + ), + }, + }, + }; + + let serialized = legacy_notify_json(&payload)?; + let actual: Value = serde_json::from_str(&serialized)?; + assert_eq!(actual, expected_notification_json()); + + Ok(()) + } +} diff --git a/codex-rs/linux-sandbox/BUILD.bazel b/codex-rs/linux-sandbox/BUILD.bazel index 87ca8ba066..2770b97e09 100644 --- a/codex-rs/linux-sandbox/BUILD.bazel +++ b/codex-rs/linux-sandbox/BUILD.bazel @@ -1,36 +1,9 @@ -load("@rules_cc//cc:defs.bzl", "cc_library") load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "linux-sandbox", crate_name = "codex_linux_sandbox", - # Bazel wires vendored bubblewrap + libcap via :vendored-bwrap-ffi below - # and sets vendored_bwrap_available explicitly, so we skip Cargo's - # build.rs in Bazel builds. - build_script_enabled = False, - deps_extra = select({ - "@platforms//os:linux": [":vendored-bwrap-ffi"], - "//conditions:default": [], - }), - rustc_flags_extra = select({ - "@platforms//os:linux": ["--cfg=vendored_bwrap_available"], - "//conditions:default": [], - }), -) - -cc_library( - name = "vendored-bwrap-ffi", - srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], - hdrs = [ - "config.h", - "//codex-rs/vendor:bubblewrap_headers", + extra_binaries = [ + "//codex-rs/bwrap:bwrap", ], - copts = [ - "-D_GNU_SOURCE", - "-Dmain=bwrap_main", - ], - includes = ["."], - deps = ["@libcap//:libcap"], - target_compatible_with = ["@platforms//os:linux"], - visibility = ["//visibility:private"], ) diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 05967661e2..aaacee27fa 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -27,6 +27,7 @@ libc = { workspace = true } seccompiler = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } url = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] @@ -40,7 +41,3 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } - -[build-dependencies] -cc = "1" -pkg-config = "0.3" diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 5745f4816c..1b4c1e5aa7 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -12,10 +12,10 @@ outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If `bwrap` is missing, -the helper falls back to the vendored bubblewrap path compiled into this -binary. +the helper falls back to the bundled `codex-resources/bwrap` binary shipped +with Codex. Codex also surfaces a startup warning when `bwrap` is missing so users know it -is falling back to the vendored helper. Codex surfaces the same startup warning +is falling back to the bundled helper. Codex surfaces the same startup warning path when bubblewrap cannot create user namespaces. WSL2 follows the normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing because it cannot create the required user namespaces, so Codex rejects sandboxed shell @@ -28,8 +28,8 @@ commands that would enter the bubblewrap path. helper uses it. - If `bwrap` is present but too old to support `--argv0`, the helper uses a no-`--argv0` compatibility path for the inner re-exec. -- If `bwrap` is missing, the helper falls back to the vendored bubblewrap - path. +- If `bwrap` is missing, the helper falls back to the bundled + `codex-resources/bwrap` path. - If `bwrap` is missing, Codex also surfaces a startup warning instead of printing directly from the sandbox helper. - If bubblewrap cannot create user namespaces, Codex surfaces a startup warning diff --git a/codex-rs/linux-sandbox/build.rs b/codex-rs/linux-sandbox/build.rs index a2b4ca86ca..968cfc7e67 100644 --- a/codex-rs/linux-sandbox/build.rs +++ b/codex-rs/linux-sandbox/build.rs @@ -1,111 +1,3 @@ -use std::env; -use std::path::Path; -use std::path::PathBuf; - fn main() { - // Tell rustc/clippy that this is an expected cfg value. - println!("cargo:rustc-check-cfg=cfg(vendored_bwrap_available)"); - println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); - println!("cargo:rerun-if-env-changed=CODEX_SKIP_VENDORED_BWRAP"); - - // Rebuild if the vendored bwrap sources change. - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); - let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("bubblewrap.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("bind-mount.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("network.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("utils.c").display() - ); - - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os != "linux" || env::var_os("CODEX_SKIP_VENDORED_BWRAP").is_some() { - return; - } - - if let Err(err) = try_build_vendored_bwrap() { - panic!("failed to compile vendored bubblewrap for Linux target: {err}"); - } -} - -fn try_build_vendored_bwrap() -> Result<(), String> { - let manifest_dir = - PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); - let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); - let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; - let libcap = pkg_config::Config::new() - .probe("libcap") - .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; - - let config_h = out_dir.join("config.h"); - std::fs::write( - &config_h, - r#"#pragma once -#define PACKAGE_STRING "bubblewrap built at codex build-time" -"#, - ) - .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; - - let mut build = cc::Build::new(); - build - .file(src_dir.join("bubblewrap.c")) - .file(src_dir.join("bind-mount.c")) - .file(src_dir.join("network.c")) - .file(src_dir.join("utils.c")) - .include(&out_dir) - .include(&src_dir) - .define("_GNU_SOURCE", None) - // Rename `main` so we can call it via FFI. - .define("main", Some("bwrap_main")); - for include_path in libcap.include_paths { - // Use -idirafter so target sysroot headers win (musl cross builds), - // while still allowing libcap headers from the host toolchain. - build.flag(format!("-idirafter{}", include_path.display())); - } - - build.compile("build_time_bwrap"); - println!("cargo:rustc-cfg=vendored_bwrap_available"); - Ok(()) -} - -/// Resolve the bubblewrap source directory used for build-time compilation. -/// -/// Priority: -/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. -/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. -fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { - if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { - let src_dir = PathBuf::from(path); - if src_dir.exists() { - return Ok(src_dir); - } - return Err(format!( - "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", - src_dir.display() - )); - } - - let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); - if vendor_dir.exists() { - return Ok(vendor_dir); - } - - Err(format!( - "expected vendored bubblewrap at {}, but it was not found.\n\ -Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", - vendor_dir.display() - )) + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SHA256"); } diff --git a/codex-rs/linux-sandbox/config.h b/codex-rs/linux-sandbox/config.h deleted file mode 100644 index f08aa6fcee..0000000000 --- a/codex-rs/linux-sandbox/config.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -#define PACKAGE_STRING "bubblewrap built at codex build-time" diff --git a/codex-rs/linux-sandbox/src/bazel_bwrap.rs b/codex-rs/linux-sandbox/src/bazel_bwrap.rs new file mode 100644 index 0000000000..90e41c3849 --- /dev/null +++ b/codex-rs/linux-sandbox/src/bazel_bwrap.rs @@ -0,0 +1,68 @@ +#[cfg(debug_assertions)] +use std::fs::File; +#[cfg(debug_assertions)] +use std::io::BufRead; +use std::path::PathBuf; + +#[cfg(debug_assertions)] +const BAZEL_BWRAP_ENV_VAR: &str = "CARGO_BIN_EXE_bwrap"; + +#[cfg(debug_assertions)] +pub(crate) fn candidate() -> Option { + if option_env!("BAZEL_PACKAGE").is_none() || !runfiles_env_present() { + return None; + } + + let raw = PathBuf::from(std::env::var_os(BAZEL_BWRAP_ENV_VAR)?); + if raw.is_absolute() { + return Some(raw); + } + resolve_runfile(raw.to_str()?) +} + +#[cfg(not(debug_assertions))] +pub(crate) fn candidate() -> Option { + None +} + +#[cfg(debug_assertions)] +fn runfiles_env_present() -> bool { + std::env::var_os("RUNFILES_DIR").is_some() + || std::env::var_os("TEST_SRCDIR").is_some() + || std::env::var_os("RUNFILES_MANIFEST_FILE").is_some() +} + +#[cfg(debug_assertions)] +fn resolve_runfile(logical_path: &str) -> Option { + let mut logical_paths = vec![logical_path.to_string()]; + if let Ok(workspace) = std::env::var("TEST_WORKSPACE") + && !workspace.is_empty() + { + logical_paths.push(format!("{workspace}/{logical_path}")); + } + + for root_env in ["RUNFILES_DIR", "TEST_SRCDIR"] { + let Some(root) = std::env::var_os(root_env) else { + continue; + }; + let root = PathBuf::from(root); + for logical_path in &logical_paths { + let candidate = root.join(logical_path); + if candidate.exists() { + return Some(candidate); + } + } + } + + let manifest = PathBuf::from(std::env::var_os("RUNFILES_MANIFEST_FILE")?); + let file = File::open(manifest).ok()?; + for line in std::io::BufReader::new(file).lines().map_while(Result::ok) { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + if logical_paths.iter().any(|logical_path| logical_path == key) { + return Some(PathBuf::from(value)); + } + } + None +} diff --git a/codex-rs/linux-sandbox/src/bundled_bwrap.rs b/codex-rs/linux-sandbox/src/bundled_bwrap.rs new file mode 100644 index 0000000000..505377907f --- /dev/null +++ b/codex-rs/linux-sandbox/src/bundled_bwrap.rs @@ -0,0 +1,276 @@ +use std::ffi::CStr; +use std::ffi::CString; +use std::fs::File; +use std::io::Read; +use std::os::fd::AsRawFd; +use std::os::raw::c_char; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; + +use crate::bazel_bwrap; +use crate::exec_util::argv_to_cstrings; +use crate::exec_util::make_files_inheritable; +use codex_utils_absolute_path::AbsolutePathBuf; +use sha2::Digest as _; +use sha2::Sha256; + +const SHA256_HEX_LEN: usize = 64; +const NULL_SHA256_DIGEST: [u8; 32] = [0; 32]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BundledBwrapLauncher { + program: AbsolutePathBuf, +} + +pub(crate) fn launcher() -> Option { + let current_exe = std::env::current_exe().ok()?; + find_for_exe(¤t_exe).map(|program| BundledBwrapLauncher { program }) +} + +impl BundledBwrapLauncher { + pub(crate) fn exec(&self, argv: Vec, preserved_files: Vec) -> ! { + let bwrap_file = File::open(self.program.as_path()).unwrap_or_else(|err| { + panic!( + "failed to open bundled bubblewrap {}: {err}", + self.program.as_path().display() + ) + }); + verify_digest(&bwrap_file, expected_sha256(), self.program.as_path()) + .unwrap_or_else(|err| panic!("{err}")); + + make_files_inheritable(&preserved_files); + + let fd_path = format!("/proc/self/fd/{}", bwrap_file.as_raw_fd()); + let program_cstring = CString::new(fd_path.as_str()) + .unwrap_or_else(|err| panic!("invalid bundled bubblewrap fd path: {err}")); + let cstrings = argv_to_cstrings(&argv); + let mut argv_ptrs: Vec<*const c_char> = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: `program_cstring` and every entry in `argv_ptrs` are valid C + // strings for the duration of the call. On success `execv` does not return. + unsafe { + libc::execv(program_cstring.as_ptr(), argv_ptrs.as_ptr()); + } + let err = std::io::Error::last_os_error(); + panic!( + "failed to exec bundled bubblewrap {} via {fd_path}: {err}", + self.program.as_path().display() + ); + } +} + +fn find_for_exe(exe: &Path) -> Option { + candidates_for_exe(exe) + .into_iter() + .find(|candidate| is_executable_file(candidate)) + .map(|path| { + AbsolutePathBuf::from_absolute_path(&path).unwrap_or_else(|err| { + panic!( + "failed to normalize bundled bubblewrap path {}: {err}", + path.display() + ) + }) + }) +} + +fn candidates_for_exe(exe: &Path) -> Vec { + let Some(exe_dir) = exe.parent() else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + candidates.push(exe_dir.join("codex-resources").join("bwrap")); + if let Some(package_target_dir) = exe_dir.parent() { + candidates.push(package_target_dir.join("codex-resources").join("bwrap")); + } + candidates.push(exe_dir.join("bwrap")); + if let Some(path) = bazel_bwrap::candidate() { + candidates.push(path); + } + candidates +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + +fn expected_sha256() -> Option<[u8; 32]> { + static EXPECTED: OnceLock> = OnceLock::new(); + *EXPECTED.get_or_init(|| { + let raw_digest = option_env!("CODEX_BWRAP_SHA256")?; + let digest = parse_sha256_hex(raw_digest) + .unwrap_or_else(|err| panic!("invalid CODEX_BWRAP_SHA256 value: {err}")); + (digest != NULL_SHA256_DIGEST).then_some(digest) + }) +} + +fn verify_digest(file: &File, expected: Option<[u8; 32]>, path: &Path) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + + let mut file = file + .try_clone() + .map_err(|err| format!("failed to clone bundled bubblewrap fd: {err}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file.read(&mut buffer).map_err(|err| { + format!( + "failed to read bundled bubblewrap {} for digest verification: {err}", + path.display() + ) + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + let actual: [u8; 32] = hasher.finalize().into(); + if actual == expected { + return Ok(()); + } + + Err(format!( + "bundled bubblewrap digest mismatch for {}: expected sha256:{}, got sha256:{}", + path.display(), + bytes_to_hex(&expected), + bytes_to_hex(&actual), + )) +} + +fn parse_sha256_hex(raw: &str) -> Result<[u8; 32], String> { + if raw.len() != SHA256_HEX_LEN { + return Err(format!( + "expected {SHA256_HEX_LEN} hex characters, got {}", + raw.len() + )); + } + + let mut digest = [0_u8; 32]; + for (index, byte) in digest.iter_mut().enumerate() { + let start = index * 2; + *byte = u8::from_str_radix(&raw[start..start + 2], 16) + .map_err(|err| format!("invalid hex byte at offset {start}: {err}"))?; + } + Ok(digest) +} + +fn bytes_to_hex(bytes: &[u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut hex = String::with_capacity(SHA256_HEX_LEN); + for byte in bytes { + hex.push(HEX[(byte >> 4) as usize] as char); + hex.push(HEX[(byte & 0x0f) as usize] as char); + } + hex +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + use tempfile::NamedTempFile; + use tempfile::tempdir; + + #[test] + fn finds_standalone_bundled_bwrap_next_to_exe_resources() { + let temp_dir = tempdir().expect("temp dir"); + let exe = temp_dir.path().join("codex"); + let expected_bwrap = temp_dir.path().join("codex-resources").join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn finds_npm_bundled_bwrap_next_to_target_vendor_dir() { + let temp_dir = tempdir().expect("temp dir"); + let target_dir = temp_dir.path().join("vendor/x86_64-unknown-linux-musl"); + let exe = target_dir.join("codex").join("codex"); + let expected_bwrap = target_dir.join("codex-resources").join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn finds_adjacent_dev_bwrap() { + let temp_dir = tempdir().expect("temp dir"); + let exe = temp_dir.path().join("codex"); + let expected_bwrap = temp_dir.path().join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn digest_verification_skips_missing_expected_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + + verify_digest(file.as_file(), /*expected*/ None, file.path()) + .expect("missing digest should skip verification"); + } + + #[test] + fn digest_verification_accepts_matching_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + let expected: [u8; 32] = Sha256::digest(b"contents").into(); + + verify_digest(file.as_file(), Some(expected), file.path()) + .expect("matching digest should verify"); + } + + #[test] + fn digest_verification_rejects_mismatched_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + + let err = verify_digest(file.as_file(), Some([0xab; 32]), file.path()) + .expect_err("mismatched digest should fail"); + assert!(err.contains("bundled bubblewrap digest mismatch")); + } + + #[test] + fn parses_sha256_hex_digest() { + assert_eq!(parse_sha256_hex(&"ab".repeat(32)), Ok([0xab; 32])); + assert_eq!(parse_sha256_hex(&"00".repeat(32)), Ok(NULL_SHA256_DIGEST)); + assert!(parse_sha256_hex("ab").is_err()); + assert!(parse_sha256_hex(&format!("{}xx", "00".repeat(31))).is_err()); + } + + fn write_executable(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dir"); + } + fs::write(path, b"").expect("write executable"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("set executable permissions"); + } +} diff --git a/codex-rs/linux-sandbox/src/exec_util.rs b/codex-rs/linux-sandbox/src/exec_util.rs new file mode 100644 index 0000000000..594c7a725d --- /dev/null +++ b/codex-rs/linux-sandbox/src/exec_util.rs @@ -0,0 +1,77 @@ +use std::ffi::CString; +use std::fs::File; +use std::os::fd::AsRawFd; + +pub(crate) fn argv_to_cstrings(argv: &[String]) -> Vec { + let mut cstrings: Vec = Vec::with_capacity(argv.len()); + for arg in argv { + match CString::new(arg.as_str()) { + Ok(value) => cstrings.push(value), + Err(err) => panic!("failed to convert argv to CString: {err}"), + } + } + cstrings +} + +pub(crate) fn make_files_inheritable(files: &[File]) { + for file in files { + clear_cloexec(file.as_raw_fd()); + } +} + +fn clear_cloexec(fd: libc::c_int) { + // SAFETY: `fd` is an owned descriptor kept alive by `files`. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to read fd flags for preserved bubblewrap file descriptor {fd}: {err}"); + } + let cleared_flags = flags & !libc::FD_CLOEXEC; + if cleared_flags == flags { + return; + } + + // SAFETY: `fd` is valid and we are only clearing FD_CLOEXEC. + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, cleared_flags) }; + if result < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to clear CLOEXEC for preserved bubblewrap file descriptor {fd}: {err}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::NamedTempFile; + + #[test] + fn preserved_files_are_made_inheritable() { + let file = NamedTempFile::new().expect("temp file"); + set_cloexec(file.as_file().as_raw_fd()); + + make_files_inheritable(std::slice::from_ref(file.as_file())); + + assert_eq!(fd_flags(file.as_file().as_raw_fd()) & libc::FD_CLOEXEC, 0); + } + + fn set_cloexec(fd: libc::c_int) { + let flags = fd_flags(fd); + // SAFETY: `fd` is valid for the duration of the test. + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + if result < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to set CLOEXEC for test fd {fd}: {err}"); + } + } + + fn fd_flags(fd: libc::c_int) -> libc::c_int { + // SAFETY: `fd` is valid for the duration of the test. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to read fd flags for test fd {fd}: {err}"); + } + flags + } +} diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 577ef52405..9b80075531 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -1,20 +1,24 @@ +use std::ffi::CStr; use std::ffi::CString; use std::fs::File; -use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::process::Command; use std::sync::OnceLock; -use crate::vendored_bwrap::exec_vendored_bwrap; +use crate::bundled_bwrap; +use crate::bundled_bwrap::BundledBwrapLauncher; +use crate::exec_util::argv_to_cstrings; +use crate::exec_util::make_files_inheritable; use codex_sandboxing::find_system_bwrap_in_path; use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Debug, Clone, PartialEq, Eq)] enum BubblewrapLauncher { System(SystemBwrapLauncher), - Vendored, + Bundled(BundledBwrapLauncher), + Unavailable, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -34,30 +38,44 @@ pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { BubblewrapLauncher::System(launcher) => { exec_system_bwrap(&launcher.program, argv, preserved_files) } - BubblewrapLauncher::Vendored => exec_vendored_bwrap(argv, preserved_files), + BubblewrapLauncher::Bundled(launcher) => launcher.exec(argv, preserved_files), + BubblewrapLauncher::Unavailable => { + panic!( + "bubblewrap is unavailable: no system bwrap was found on PATH and no bundled \ + codex-resources/bwrap binary was found next to the Codex executable" + ) + } } } fn preferred_bwrap_launcher() -> BubblewrapLauncher { static LAUNCHER: OnceLock = OnceLock::new(); LAUNCHER - .get_or_init(|| match find_system_bwrap_in_path() { - Some(path) => preferred_bwrap_launcher_for_path(&path), - None => BubblewrapLauncher::Vendored, + .get_or_init(|| { + if let Some(path) = find_system_bwrap_in_path() + && let Some(launcher) = system_bwrap_launcher_for_path(&path) + { + return BubblewrapLauncher::System(launcher); + } + + match bundled_bwrap::launcher() { + Some(launcher) => BubblewrapLauncher::Bundled(launcher), + None => BubblewrapLauncher::Unavailable, + } }) .clone() } -fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher { - preferred_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) +fn system_bwrap_launcher_for_path(system_bwrap_path: &Path) -> Option { + system_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) } -fn preferred_bwrap_launcher_for_path_with_probe( +fn system_bwrap_launcher_for_path_with_probe( system_bwrap_path: &Path, system_bwrap_capabilities: impl FnOnce(&Path) -> Option, -) -> BubblewrapLauncher { +) -> Option { if !system_bwrap_path.is_file() { - return BubblewrapLauncher::Vendored; + return None; } let Some(SystemBwrapCapabilities { @@ -65,7 +83,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( supports_perms: true, }) = system_bwrap_capabilities(system_bwrap_path) else { - return BubblewrapLauncher::Vendored; + return None; }; let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) { Ok(path) => path, @@ -74,7 +92,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( system_bwrap_path.display() ), }; - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: system_bwrap_path, supports_argv0, }) @@ -83,7 +101,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( pub(crate) fn preferred_bwrap_supports_argv0() -> bool { match preferred_bwrap_launcher() { BubblewrapLauncher::System(launcher) => launcher.supports_argv0, - BubblewrapLauncher::Vendored => true, + BubblewrapLauncher::Bundled(_) | BubblewrapLauncher::Unavailable => true, } } @@ -117,7 +135,11 @@ fn exec_system_bwrap( let program = CString::new(program.as_path().as_os_str().as_bytes()) .unwrap_or_else(|err| panic!("invalid system bubblewrap path: {err}")); let cstrings = argv_to_cstrings(&argv); - let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); + let mut argv_ptrs: Vec<*const c_char> = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect(); argv_ptrs.push(std::ptr::null()); // SAFETY: `program` and every entry in `argv_ptrs` are valid C strings for @@ -129,43 +151,6 @@ fn exec_system_bwrap( panic!("failed to exec system bubblewrap {program_path}: {err}"); } -fn argv_to_cstrings(argv: &[String]) -> Vec { - let mut cstrings: Vec = Vec::with_capacity(argv.len()); - for arg in argv { - match CString::new(arg.as_str()) { - Ok(value) => cstrings.push(value), - Err(err) => panic!("failed to convert argv to CString: {err}"), - } - } - cstrings -} - -fn make_files_inheritable(files: &[File]) { - for file in files { - clear_cloexec(file.as_raw_fd()); - } -} - -fn clear_cloexec(fd: libc::c_int) { - // SAFETY: `fd` is an owned descriptor kept alive by `files`. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; - if flags < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to read fd flags for preserved bubblewrap file descriptor {fd}: {err}"); - } - let cleared_flags = flags & !libc::FD_CLOEXEC; - if cleared_flags == flags { - return; - } - - // SAFETY: `fd` is valid and we are only clearing FD_CLOEXEC. - let result = unsafe { libc::fcntl(fd, libc::F_SETFD, cleared_flags) }; - if result < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to clear CLOEXEC for preserved bubblewrap file descriptor {fd}: {err}"); - } -} - #[cfg(test)] mod tests { use super::*; @@ -179,13 +164,13 @@ mod tests { let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { Some(SystemBwrapCapabilities { supports_argv0: true, supports_perms: true, }) }), - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: expected, supports_argv0: true, }) @@ -198,13 +183,13 @@ mod tests { let fake_bwrap_path = fake_bwrap.path(); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { Some(SystemBwrapCapabilities { supports_argv0: false, supports_perms: true, }) }), - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"), supports_argv0: false, }) @@ -212,55 +197,25 @@ mod tests { } #[test] - fn falls_back_to_vendored_when_system_bwrap_lacks_perms() { + fn ignores_system_bwrap_when_system_bwrap_lacks_perms() { let fake_bwrap = NamedTempFile::new().expect("temp file"); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| { Some(SystemBwrapCapabilities { supports_argv0: false, supports_perms: false, }) }), - BubblewrapLauncher::Vendored + None ); } #[test] - fn falls_back_to_vendored_when_system_bwrap_is_missing() { + fn ignores_system_bwrap_when_system_bwrap_is_missing() { assert_eq!( - preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), - BubblewrapLauncher::Vendored + system_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), + None ); } - - #[test] - fn preserved_files_are_made_inheritable_for_system_exec() { - let file = NamedTempFile::new().expect("temp file"); - set_cloexec(file.as_file().as_raw_fd()); - - make_files_inheritable(std::slice::from_ref(file.as_file())); - - assert_eq!(fd_flags(file.as_file().as_raw_fd()) & libc::FD_CLOEXEC, 0); - } - - fn set_cloexec(fd: libc::c_int) { - let flags = fd_flags(fd); - // SAFETY: `fd` is valid for the duration of the test. - let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; - if result < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to set CLOEXEC for test fd {fd}: {err}"); - } - } - - fn fd_flags(fd: libc::c_int) -> libc::c_int { - // SAFETY: `fd` is valid for the duration of the test. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; - if flags < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to read fd flags for test fd {fd}: {err}"); - } - flags - } } diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 900287c99d..478cd6c379 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -4,8 +4,14 @@ //! - in-process restrictions (`no_new_privs` + seccomp), and //! - bubblewrap for filesystem isolation. #[cfg(target_os = "linux")] +mod bazel_bwrap; +#[cfg(target_os = "linux")] +mod bundled_bwrap; +#[cfg(target_os = "linux")] mod bwrap; #[cfg(target_os = "linux")] +mod exec_util; +#[cfg(target_os = "linux")] mod landlock; #[cfg(target_os = "linux")] mod launcher; @@ -13,8 +19,6 @@ mod launcher; mod linux_run_main; #[cfg(target_os = "linux")] mod proxy_routing; -#[cfg(target_os = "linux")] -mod vendored_bwrap; #[cfg(target_os = "linux")] pub fn run_main() -> ! { diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index c88a28b324..346b1f14c0 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -25,6 +25,7 @@ use crate::launcher::exec_bwrap; use crate::launcher::preferred_bwrap_supports_argv0; use crate::proxy_routing::activate_proxy_routes_in_netns; use crate::proxy_routing::prepare_host_proxy_route_spec; +use codex_protocol::error::Result as CodexResult; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::NetworkSandboxPolicy; @@ -333,6 +334,7 @@ fn run_bwrap_with_proc_fallback( file_system_sandbox_policy, network_mode, ) + .unwrap_or_else(|err| exit_with_bwrap_build_error(err)) { // Keep the retry silent so sandbox-internal diagnostics do not leak into the // child process stderr stream. @@ -350,7 +352,8 @@ fn run_bwrap_with_proc_fallback( sandbox_policy_cwd, command_cwd, options, - ); + ) + .unwrap_or_else(|err| exit_with_bwrap_build_error(err)); apply_inner_command_argv0(&mut bwrap_args.args); run_or_exec_bwrap(bwrap_args); } @@ -374,24 +377,28 @@ fn build_bwrap_argv( sandbox_policy_cwd: &Path, command_cwd: &Path, options: BwrapOptions, -) -> crate::bwrap::BwrapArgs { +) -> CodexResult { let bwrap_args = create_bwrap_command_args( inner, file_system_sandbox_policy, sandbox_policy_cwd, command_cwd, options, - ) - .unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}")); + )?; let mut argv = vec!["bwrap".to_string()]; argv.extend(bwrap_args.args); - crate::bwrap::BwrapArgs { + Ok(crate::bwrap::BwrapArgs { args: argv, preserved_files: bwrap_args.preserved_files, synthetic_mount_targets: bwrap_args.synthetic_mount_targets, protected_create_targets: bwrap_args.protected_create_targets, - } + }) +} + +fn exit_with_bwrap_build_error(err: codex_protocol::error::CodexErr) -> ! { + eprintln!("error building bubblewrap command: {err}"); + std::process::exit(1); } fn apply_inner_command_argv0(argv: &mut Vec) { @@ -439,15 +446,15 @@ fn preflight_proc_mount_support( command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, -) -> bool { +) -> CodexResult { let preflight_argv = build_preflight_bwrap_argv( sandbox_policy_cwd, command_cwd, file_system_sandbox_policy, network_mode, - ); + )?; let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); - !is_proc_mount_failure(stderr.as_str()) + Ok(!is_proc_mount_failure(stderr.as_str())) } fn build_preflight_bwrap_argv( @@ -455,7 +462,7 @@ fn build_preflight_bwrap_argv( command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, -) -> crate::bwrap::BwrapArgs { +) -> CodexResult { let preflight_command = vec![resolve_true_command()]; build_bwrap_argv( preflight_command, @@ -1235,7 +1242,10 @@ fn synthetic_mount_marker_dir(path: &Path) -> PathBuf { } fn synthetic_mount_registry_root() -> PathBuf { - std::env::temp_dir().join("codex-bwrap-synthetic-mount-targets") + let effective_uid = unsafe { libc::geteuid() }; + std::env::temp_dir().join(format!( + "codex-bwrap-synthetic-mount-targets-{effective_uid}" + )) } fn hash_path(path: &Path) -> u64 { diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 228cea6e5d..4441af7809 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -61,6 +61,7 @@ fn inserts_bwrap_argv0_before_command_separator() { ..Default::default() }, ) + .expect("build bwrap argv") .args; apply_inner_command_argv0_for_launcher( &mut argv, @@ -104,6 +105,7 @@ fn rewrites_inner_command_path_when_bwrap_lacks_argv0() { ..Default::default() }, ) + .expect("build bwrap argv") .args; apply_inner_command_argv0_for_launcher( &mut argv, @@ -172,6 +174,7 @@ fn inserts_unshare_net_when_network_isolation_requested() { ..Default::default() }, ) + .expect("build bwrap argv") .args; assert!(argv.contains(&"--unshare-net".to_string())); } @@ -190,6 +193,7 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() { ..Default::default() }, ) + .expect("build bwrap argv") .args; assert!(argv.contains(&"--unshare-net".to_string())); } @@ -265,6 +269,7 @@ fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() { &FileSystemSandboxPolicy::unrestricted(), mode, ) + .expect("build preflight argv") .args; assert!(argv.iter().any(|arg| arg == "--")); } @@ -297,6 +302,17 @@ fn cleanup_synthetic_mount_targets_removes_only_empty_mount_targets() { assert!(!missing_file.exists()); } +#[test] +fn synthetic_mount_registry_root_is_unique_to_effective_user() { + let effective_uid = unsafe { libc::geteuid() }; + assert_eq!( + synthetic_mount_registry_root(), + std::env::temp_dir().join(format!( + "codex-bwrap-synthetic-mount-targets-{effective_uid}" + )) + ); +} + #[test] fn cleanup_synthetic_mount_targets_waits_for_other_active_registrations() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/codex-rs/linux-sandbox/src/vendored_bwrap.rs b/codex-rs/linux-sandbox/src/vendored_bwrap.rs deleted file mode 100644 index a2da14db05..0000000000 --- a/codex-rs/linux-sandbox/src/vendored_bwrap.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Build-time bubblewrap entrypoint. -//! -//! On Linux targets, the build script compiles bubblewrap's C sources and -//! exposes a `bwrap_main` symbol that we can call via FFI. - -#[cfg(vendored_bwrap_available)] -mod imp { - use std::ffi::CString; - use std::fs::File; - use std::os::raw::c_char; - - unsafe extern "C" { - fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; - } - - fn argv_to_cstrings(argv: &[String]) -> Vec { - let mut cstrings: Vec = Vec::with_capacity(argv.len()); - for arg in argv { - match CString::new(arg.as_str()) { - Ok(value) => cstrings.push(value), - Err(err) => panic!("failed to convert argv to CString: {err}"), - } - } - cstrings - } - - /// Run the build-time bubblewrap `main` function and return its exit code. - /// - /// On success, bubblewrap will `execve` into the target program and this - /// function will never return. A return value therefore implies failure. - pub(crate) fn run_vendored_bwrap_main( - argv: &[String], - _preserved_files: &[File], - ) -> libc::c_int { - let cstrings = argv_to_cstrings(argv); - - let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); - argv_ptrs.push(std::ptr::null()); - - // SAFETY: We provide a null-terminated argv vector whose pointers - // remain valid for the duration of the call. - unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) } - } - - /// Execute the build-time bubblewrap `main` function with the given argv. - pub(crate) fn exec_vendored_bwrap(argv: Vec, preserved_files: Vec) -> ! { - let exit_code = run_vendored_bwrap_main(&argv, &preserved_files); - std::process::exit(exit_code); - } -} - -#[cfg(not(vendored_bwrap_available))] -mod imp { - use std::fs::File; - - /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn run_vendored_bwrap_main( - _argv: &[String], - _preserved_files: &[File], - ) -> libc::c_int { - panic!( - r#"build-time bubblewrap is not available in this build. -codex-linux-sandbox should always compile vendored bubblewrap on Linux targets. -Notes: -- ensure the target OS is Linux -- libcap headers must be available via pkg-config -- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# - ); - } - - /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn exec_vendored_bwrap(_argv: Vec, _preserved_files: Vec) -> ! { - let _ = run_vendored_bwrap_main(&[], &[]); - unreachable!("run_vendored_bwrap_main should always panic in this configuration") - } -} - -pub(crate) use imp::exec_vendored_bwrap; diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index efbcd0b486..87e4ce68ae 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -40,7 +40,7 @@ const NETWORK_TIMEOUT_MS: u64 = 10_000; #[cfg(target_arch = "aarch64")] const NETWORK_TIMEOUT_MS: u64 = 10_000; -const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; fn create_env_from_core_vars() -> HashMap { let policy = ShellEnvironmentPolicy::default(); @@ -587,6 +587,59 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() { assert_ne!(codex_output.exit_code, 0); } +#[tokio::test] +async fn sandbox_reports_codex_symlink_build_failure_without_panicking() { + if should_skip_bwrap_tests().await { + eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable"); + return; + } + + use std::os::unix::fs::symlink; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let decoy = tmpdir.path().join("decoy-codex"); + std::fs::create_dir_all(&decoy).expect("create decoy dir"); + + let dot_codex = tmpdir.path().join(".codex"); + symlink(&decoy, &dot_codex).expect("create .codex symlink"); + + let output = match run_cmd_result_with_writable_roots( + &["bash", "-lc", "true"], + &[tmpdir.path().to_path_buf()], + LONG_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await + { + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output, + result => panic!(".codex symlink build failure should deny: {result:?}"), + }; + + assert_eq!(output.exit_code, 1); + assert!( + output + .stderr + .text + .contains("error building bubblewrap command:"), + "stderr: {}", + output.stderr.text + ); + assert!( + output + .stderr + .text + .contains("cannot enforce sandbox read-only path"), + "stderr: {}", + output.stderr.text + ); + assert!( + !output.stderr.text.contains("panicked at"), + "stderr: {}", + output.stderr.text + ); +} + #[tokio::test] async fn sandbox_keeps_parent_repo_discovery_while_blocking_child_metadata() { if should_skip_bwrap_tests().await { diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 932b7981d3..d1aa6856c4 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -15,7 +15,7 @@ use std::process::Stdio; use std::time::Duration; use tokio::process::Command; -const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; const NETWORK_TIMEOUT_MS: u64 = 4_000; const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[ "loopback: Failed RTM_NEWADDR", @@ -82,7 +82,7 @@ fn is_managed_proxy_permission_error(stderr: &str) -> bool { async fn managed_proxy_skip_reason() -> Option { if should_skip_bwrap_tests().await { - return Some("vendored bwrap was not built in this environment".to_string()); + return Some("bubblewrap is unavailable in this environment".to_string()); } let mut env = create_env_from_core_vars(); diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 80ec9d07a4..fe57be06fa 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -84,7 +84,7 @@ fn login_with_api_key_overwrites_existing_auth_json() { } #[tokio::test] -async fn login_with_agent_identity_writes_only_token() { +async fn login_with_access_token_writes_only_token() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); let record = agent_identity_record("account-123"); @@ -99,14 +99,14 @@ async fn login_with_agent_identity_writes_only_token() { .await; let chatgpt_base_url = format!("{}/backend-api", server.uri()); - super::login_with_agent_identity( + super::login_with_access_token( dir.path(), &agent_identity, AuthCredentialsStoreMode::File, Some(&chatgpt_base_url), ) .await - .expect("login_with_agent_identity should succeed"); + .expect("login_with_access_token should succeed"); let storage = FileAuthStorage::new(dir.path().to_path_buf()); let auth = storage @@ -123,27 +123,27 @@ async fn login_with_agent_identity_writes_only_token() { } #[tokio::test] -async fn login_with_agent_identity_rejects_invalid_jwt() { +async fn login_with_access_token_rejects_invalid_jwt() { let dir = tempdir().unwrap(); - let err = super::login_with_agent_identity( + let err = super::login_with_access_token( dir.path(), "not-a-jwt", AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) .await - .expect_err("invalid Agent Identity token should fail"); + .expect_err("invalid access token should fail"); assert_eq!(err.kind(), std::io::ErrorKind::Other); assert!( !get_auth_file(dir.path()).exists(), - "invalid Agent Identity token should not write auth.json" + "invalid access token should not write auth.json" ); } #[tokio::test] -async fn login_with_agent_identity_rejects_unsigned_jwt() { +async fn login_with_access_token_rejects_unsigned_jwt() { let dir = tempdir().unwrap(); let record = agent_identity_record("account-123"); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); @@ -156,18 +156,18 @@ async fn login_with_agent_identity_rejects_unsigned_jwt() { .await; let chatgpt_base_url = format!("{}/backend-api", server.uri()); - super::login_with_agent_identity( + super::login_with_access_token( dir.path(), &agent_identity, AuthCredentialsStoreMode::File, Some(&chatgpt_base_url), ) .await - .expect_err("unsigned Agent Identity token should fail"); + .expect_err("unsigned access token should fail"); assert!( !get_auth_file(dir.path()).exists(), - "unsigned Agent Identity token should not write auth.json" + "unsigned access token should not write auth.json" ); server.verify().await; } @@ -176,7 +176,7 @@ async fn login_with_agent_identity_rejects_unsigned_jwt() { #[serial(codex_auth_env)] async fn missing_auth_json_returns_none() { let dir = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let auth = CodexAuth::from_auth_storage( dir.path(), AuthCredentialsStoreMode::File, @@ -191,7 +191,7 @@ async fn missing_auth_json_returns_none() { #[serial(codex_auth_env)] async fn pro_account_with_no_api_key_uses_chatgpt_auth() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let fake_jwt = write_auth_file( AuthFileParams { openai_api_key: None, @@ -250,7 +250,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { #[serial(codex_auth_env)] async fn loads_api_key_from_auth_json() { let dir = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let auth_file = dir.path().join("auth.json"); std::fs::write( auth_file, @@ -324,7 +324,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { #[serial(codex_auth_env)] async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); write_auth_file( AuthFileParams { openai_api_key: None, @@ -704,9 +704,13 @@ impl Drop for EnvVarGuard { } } +fn remove_access_token_env_var() -> EnvVarGuard { + EnvVarGuard::remove(CODEX_ACCESS_TOKEN_ENV_VAR) +} + #[tokio::test] #[serial(codex_auth_env)] -async fn load_auth_reads_agent_identity_from_env() { +async fn load_auth_reads_access_token_from_env() { let codex_home = tempdir().unwrap(); let expected_record = agent_identity_record("account-123"); let agent_identity = @@ -727,7 +731,7 @@ async fn load_auth_reads_agent_identity_from_env() { .expect(1) .mount(&server) .await; - let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); let chatgpt_base_url = format!("{}/backend-api", server.uri()); let _authapi_guard = @@ -760,7 +764,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { let codex_home = tempdir().unwrap(); let record = agent_identity_record("account-123"); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); - let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); let auth = super::load_auth( @@ -780,7 +784,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { #[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_method_mismatch() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) .expect("seed api key"); @@ -805,7 +809,7 @@ async fn enforce_login_restrictions_logs_out_for_method_mismatch() { #[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, @@ -837,7 +841,7 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { #[serial(codex_auth_env)] async fn enforce_login_restrictions_allows_matching_workspace() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, @@ -869,7 +873,7 @@ async fn enforce_login_restrictions_allows_matching_workspace() { async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) .expect("seed api key"); @@ -893,7 +897,7 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f #[serial(codex_auth_env)] async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let codex_home = tempdir().unwrap(); let config = build_config( @@ -1069,7 +1073,7 @@ async fn assert_agent_identity_plan_alias( #[serial(codex_auth_env)] async fn plan_type_maps_known_plan() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, @@ -1097,7 +1101,7 @@ async fn plan_type_maps_known_plan() { #[serial(codex_auth_env)] async fn plan_type_maps_self_serve_business_usage_based_plan() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, @@ -1128,7 +1132,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { #[serial(codex_auth_env)] async fn plan_type_maps_enterprise_cbp_usage_based_plan() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, @@ -1159,7 +1163,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { #[serial(codex_auth_env)] async fn plan_type_maps_unknown_to_unknown() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, @@ -1187,7 +1191,7 @@ async fn plan_type_maps_unknown_to_unknown() { #[serial(codex_auth_env)] async fn missing_plan_type_maps_to_unknown() { let codex_home = tempdir().unwrap(); - let _agent_guard = EnvVarGuard::remove(CODEX_AGENT_IDENTITY_ENV_VAR); + let _access_token_guard = remove_access_token_env_var(); let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 29897db7be..08d4a21c50 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -464,7 +464,7 @@ impl ChatgptAuth { pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; -pub const CODEX_AGENT_IDENTITY_ENV_VAR: &str = "CODEX_AGENT_IDENTITY"; +pub const CODEX_ACCESS_TOKEN_ENV_VAR: &str = "CODEX_ACCESS_TOKEN"; pub fn read_openai_api_key_from_env() -> Option { env::var(OPENAI_API_KEY_ENV_VAR) @@ -474,14 +474,15 @@ pub fn read_openai_api_key_from_env() -> Option { } pub fn read_codex_api_key_from_env() -> Option { - env::var(CODEX_API_KEY_ENV_VAR) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) + read_non_empty_env_var(CODEX_API_KEY_ENV_VAR) } -pub fn read_codex_agent_identity_from_env() -> Option { - env::var(CODEX_AGENT_IDENTITY_ENV_VAR) +pub fn read_codex_access_token_from_env() -> Option { + read_non_empty_env_var(CODEX_ACCESS_TOKEN_ENV_VAR) +} + +fn read_non_empty_env_var(key: &str) -> Option { + env::var(key) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -540,10 +541,10 @@ pub fn login_with_api_key( save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } -/// Writes an `auth.json` that contains only the Agent Identity token. -pub async fn login_with_agent_identity( +/// Writes an `auth.json` that contains only the access token. +pub async fn login_with_access_token( codex_home: &Path, - agent_identity: &str, + access_token: &str, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, ) -> std::io::Result<()> { @@ -551,13 +552,13 @@ pub async fn login_with_agent_identity( .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) .trim_end_matches('/') .to_string(); - verified_agent_identity_record(agent_identity, &base_url).await?; + verified_agent_identity_record(access_token, &base_url).await?; let auth_dot_json = AuthDotJson { auth_mode: Some(ApiAuthMode::AgentIdentity), openai_api_key: None, tokens: None, last_refresh: None, - agent_identity: Some(agent_identity.to_string()), + agent_identity: Some(access_token.to_string()), }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -753,7 +754,7 @@ async fn load_auth( return Ok(None); } - if let Some(agent_identity) = read_codex_agent_identity_from_env() { + if let Some(agent_identity) = read_codex_access_token_from_env() { return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url) .await .map(Some); diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 3049b6f6bc..990cf8b80e 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -22,7 +22,7 @@ pub use auth::AuthDotJson; pub use auth::AuthManager; pub use auth::AuthManagerConfig; pub use auth::CLIENT_ID; -pub use auth::CODEX_AGENT_IDENTITY_ENV_VAR; +pub use auth::CODEX_ACCESS_TOKEN_ENV_VAR; pub use auth::CODEX_API_KEY_ENV_VAR; pub use auth::CodexAuth; pub use auth::ExternalAuth; @@ -38,11 +38,11 @@ pub use auth::UnauthorizedRecovery; pub use auth::default_client; pub use auth::enforce_login_restrictions; pub use auth::load_auth_dot_json; -pub use auth::login_with_agent_identity; +pub use auth::login_with_access_token; pub use auth::login_with_api_key; pub use auth::logout; pub use auth::logout_with_revoke; -pub use auth::read_codex_agent_identity_from_env; +pub use auth::read_codex_access_token_from_env; pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 4070cc7e11..b462022dbc 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -318,9 +318,6 @@ async fn run_codex_tool_session_inner( EventMsg::SessionConfigured(_) => { tracing::error!("unexpected SessionConfigured event"); } - EventMsg::ThreadNameUpdated(_) => { - // Ignore session metadata updates in MCP tool runner. - } EventMsg::ThreadGoalUpdated(_) => { // Ignore thread goal metadata updates in MCP tool runner. } @@ -337,8 +334,6 @@ async fn run_codex_tool_session_inner( | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) @@ -351,7 +346,6 @@ async fn run_codex_tool_session_inner( | EventMsg::TurnDiff(_) | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) - | EventMsg::GetHistoryEntryResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 75372c6e7c..2fd94e586a 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; @@ -83,7 +84,6 @@ pub async fn run_main( std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; set_default_client_residency_requirement(config.enforce_residency.value()); - let state_db = codex_core::init_state_db(&config).await; let otel = codex_core::otel_init::build_provider( &config, @@ -113,6 +113,7 @@ pub async fn run_main( // Set up channels. let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); + let installation_id = resolve_installation_id(&config.codex_home).await?; // Task: read from stdin, push to `incoming_tx`. let stdin_reader_handle = tokio::spawn({ @@ -140,15 +141,19 @@ pub async fn run_main( // Task: process incoming messages. let processor_handle = tokio::spawn({ let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); - let mut processor = MessageProcessor::new( + let processor = MessageProcessor::new( outgoing_message_sender, arg0_paths, Arc::new(config), environment_manager, - state_db, + installation_id, ) .await; async move { + let Some(mut processor) = processor else { + error!("failed to initialize MCP processor"); + return; + }; while let Some(msg) = incoming_rx.recv().await { match msg { JsonRpcMessage::Request(r) => processor.process_request(r).await, diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 9ce46dfeda..73cdb6193b 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -2,9 +2,10 @@ use std::collections::HashMap; use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; -use codex_core::StateDbHandle; use codex_core::ThreadManager; +use codex_core::agent_graph_store_from_state_db; use codex_core::config::Config; +use codex_core::init_state_db_from_config; use codex_core::thread_store_from_config; use codex_exec_server::EnvironmentManager; use codex_login::AuthManager; @@ -54,30 +55,35 @@ impl MessageProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, environment_manager: Arc, - state_db: Option, - ) -> Self { + installation_id: String, + ) -> Option { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared_from_config( config.as_ref(), /*enable_codex_api_key_env*/ false, ) .await; + let state_db = init_state_db_from_config(config.as_ref()).await?; + let thread_store = thread_store_from_config(config.as_ref(), state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager, SessionSource::Mcp, environment_manager, /*analytics_events_client*/ None, - thread_store_from_config(config.as_ref(), state_db.clone()), - state_db.clone(), + state_db, + thread_store, + agent_graph_store, + installation_id, )); - Self { + Some(Self { outgoing, initialized: false, arg0_paths, thread_manager, running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())), - } + }) } pub(crate) async fn process_request(&mut self, request: JsonRpcRequest) { diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index eb66ea0619..b2882643ce 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -296,8 +296,10 @@ mod tests { let event = Event { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), @@ -308,8 +310,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -338,11 +338,13 @@ mod tests { let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { - session_id: conversation_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), @@ -353,8 +355,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -385,6 +385,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": session_configured_event.session_id, + "thread_id": session_configured_event.thread_id, "model": "gpt-4o", "model_provider_id": "test-provider", "approval_policy": "never", @@ -392,8 +393,6 @@ mod tests { "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, - "history_log_id": session_configured_event.history_log_id, - "history_entry_count": session_configured_event.history_entry_count, "rollout_path": rollout_file.path().to_path_buf(), } }); @@ -409,8 +408,10 @@ mod tests { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { - session_id: thread_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), @@ -421,8 +422,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -454,6 +453,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": session_configured_event.session_id, + "thread_id": session_configured_event.thread_id, "model": "gpt-4o", "model_provider_id": "test-provider", "approval_policy": "never", @@ -461,8 +461,6 @@ mod tests { "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, - "history_log_id": session_configured_event.history_log_id, - "history_entry_count": session_configured_event.history_entry_count, "rollout_path": rollout_file.path().to_path_buf(), } }); diff --git a/codex-rs/memories/README.md b/codex-rs/memories/README.md index 9195e89ada..73aefde297 100644 --- a/codex-rs/memories/README.md +++ b/codex-rs/memories/README.md @@ -10,6 +10,8 @@ Runtime orchestration for Phase 1 and Phase 2 still lives in `codex-core` under - `codex-rs/memories/read` (`codex-memories-read`) owns the read path: memory developer-instruction injection, memory citation parsing, and read-usage telemetry classification. +- `codex-rs/memories/mcp` (`codex-memories-mcp`) exposes the read-only memory + filesystem through the built-in MCP surface. - `codex-rs/memories/write` (`codex-memories-write`) owns the write path: Phase 1 and Phase 2 prompt rendering, filesystem artifact helpers, workspace diff helpers, and extension resource pruning. diff --git a/codex-rs/memories/mcp/src/backend.rs b/codex-rs/memories/mcp/src/backend.rs index a4b542cd69..929852f1b1 100644 --- a/codex-rs/memories/mcp/src/backend.rs +++ b/codex-rs/memories/mcp/src/backend.rs @@ -73,6 +73,7 @@ pub struct SearchMemoriesRequest { pub cursor: Option, pub context_lines: usize, pub case_sensitive: bool, + pub normalized: bool, pub max_results: usize, } @@ -87,11 +88,15 @@ pub struct SearchMemoriesResponse { pub truncated: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum SearchMatchMode { Any, - All, + AllOnSameLine, + AllWithinLines { + #[schemars(range(min = 1))] + line_count: usize, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] @@ -136,6 +141,8 @@ pub enum MemoriesBackendError { NotFile { path: String }, #[error("queries must not be empty or contain empty strings")] EmptyQuery, + #[error("all_within_lines.line_count must be a positive integer")] + InvalidMatchWindow, #[error("I/O error while reading memories: {0}")] Io(#[from] std::io::Error), } diff --git a/codex-rs/memories/mcp/src/local.rs b/codex-rs/memories/mcp/src/local.rs index 7ceb587dc2..97aacee118 100644 --- a/codex-rs/memories/mcp/src/local.rs +++ b/codex-rs/memories/mcp/src/local.rs @@ -58,6 +58,11 @@ impl LocalMemoriesBackend { "must stay within the memories root", )); } + if relative.components().any(is_hidden_component) { + return Err(MemoriesBackendError::NotFound { + path: relative_path.to_string(), + }); + } let components = relative.components().collect::>(); let mut scoped_path = self.root.clone(); @@ -122,6 +127,9 @@ impl MemoriesBackend for LocalMemoriesBackend { } else if metadata.is_dir() { let mut entries = Vec::new(); for path in read_sorted_dir_paths(&start).await? { + if is_hidden_path(&path) { + continue; + } let Some(metadata) = Self::metadata_or_none(&path).await? else { continue; }; @@ -216,6 +224,12 @@ impl MemoriesBackend for LocalMemoriesBackend { if queries.is_empty() || queries.iter().any(std::string::String::is_empty) { return Err(MemoriesBackendError::EmptyQuery); } + if matches!( + request.match_mode, + SearchMatchMode::AllWithinLines { line_count: 0 } + ) { + return Err(MemoriesBackendError::InvalidMatchWindow); + } let max_results = request.max_results.min(MAX_SEARCH_RESULTS); let start = self.resolve_scoped_path(request.path.as_deref()).await?; @@ -232,8 +246,12 @@ impl MemoriesBackend for LocalMemoriesBackend { }; reject_symlink(&display_relative_path(&self.root, &start), &metadata)?; - let matcher = - SearchMatcher::new(queries.clone(), request.match_mode, request.case_sensitive); + let matcher = SearchMatcher::new( + queries.clone(), + request.match_mode.clone(), + request.case_sensitive, + request.normalized, + )?; let mut matches = Vec::new(); search_entries( &self.root, @@ -288,6 +306,9 @@ async fn search_entries( let mut pending = vec![current.to_path_buf()]; while let Some(dir_path) = pending.pop() { for path in read_sorted_dir_paths(&dir_path).await? { + if is_hidden_path(&path) { + continue; + } let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else { continue; }; @@ -318,64 +339,198 @@ async fn search_file( Err(err) => return Err(err.into()), }; let lines = content.lines().collect::>(); - for (idx, line) in lines.iter().enumerate() { - let matched_queries = matcher.matched_queries(line); - if !matched_queries.is_empty() { - let start_index = idx.saturating_sub(context_lines); - let end_index = idx - .saturating_add(context_lines) - .saturating_add(1) - .min(lines.len()); - matches.push(MemorySearchMatch { - path: display_relative_path(root, path), - match_line_number: idx + 1, - content_start_line_number: start_index + 1, - content: lines[start_index..end_index].join("\n"), - matched_queries, - }); + let line_matches = lines + .iter() + .map(|line| matcher.matched_query_flags(line)) + .collect::>(); + match &matcher.match_mode { + SearchMatchMode::Any => { + for (idx, matched_query_flags) in line_matches.iter().enumerate() { + if matched_query_flags.iter().any(|matched| *matched) { + matches.push(build_search_match( + root, + path, + &lines, + idx, + idx, + context_lines, + matcher.matched_queries(matched_query_flags), + )); + } + } + } + SearchMatchMode::AllOnSameLine => { + for (idx, matched_query_flags) in line_matches.iter().enumerate() { + if matched_query_flags.iter().all(|matched| *matched) { + matches.push(build_search_match( + root, + path, + &lines, + idx, + idx, + context_lines, + matcher.matched_queries(matched_query_flags), + )); + } + } + } + SearchMatchMode::AllWithinLines { line_count } => { + let mut windows = Vec::new(); + for start_index in 0..lines.len() { + if !line_matches[start_index].iter().any(|matched| *matched) { + continue; + } + let last_allowed_index = start_index + .saturating_add(line_count.saturating_sub(1)) + .min(lines.len().saturating_sub(1)); + let mut matched_query_flags = vec![false; matcher.queries.len()]; + for (end_index, line_match_flags) in line_matches + .iter() + .enumerate() + .take(last_allowed_index + 1) + .skip(start_index) + { + for (idx, matched) in line_match_flags.iter().enumerate() { + matched_query_flags[idx] |= matched; + } + if matched_query_flags.iter().all(|matched| *matched) { + windows.push((start_index, end_index, matched_query_flags)); + break; + } + } + } + for (idx, (start_index, end_index, matched_query_flags)) in windows.iter().enumerate() { + let strictly_contains_another_window = windows.iter().enumerate().any( + |(other_idx, (other_start_index, other_end_index, _))| { + idx != other_idx + && start_index <= other_start_index + && end_index >= other_end_index + && (start_index != other_start_index || end_index != other_end_index) + }, + ); + if strictly_contains_another_window { + continue; + } + matches.push(build_search_match( + root, + path, + &lines, + *start_index, + *end_index, + context_lines, + matcher.matched_queries(matched_query_flags), + )); + } } } Ok(()) } +fn build_search_match( + root: &Path, + path: &Path, + lines: &[&str], + match_start_index: usize, + match_end_index: usize, + context_lines: usize, + matched_queries: Vec, +) -> MemorySearchMatch { + let content_start_index = match_start_index.saturating_sub(context_lines); + let content_end_index = match_end_index + .saturating_add(context_lines) + .saturating_add(1) + .min(lines.len()); + MemorySearchMatch { + path: display_relative_path(root, path), + match_line_number: match_start_index + 1, + content_start_line_number: content_start_index + 1, + content: lines[content_start_index..content_end_index].join("\n"), + matched_queries, + } +} + struct SearchMatcher { queries: Vec, - normalized_queries: Option>, + prepared_queries: Vec, + comparison: SearchComparison, match_mode: SearchMatchMode, } impl SearchMatcher { - fn new(queries: Vec, match_mode: SearchMatchMode, case_sensitive: bool) -> Self { - let normalized_queries = (!case_sensitive).then(|| { - queries - .iter() - .map(|query| query.to_lowercase()) - .collect::>() - }); - Self { + fn new( + queries: Vec, + match_mode: SearchMatchMode, + case_sensitive: bool, + normalized: bool, + ) -> Result { + let comparison = SearchComparison::new(case_sensitive, normalized); + let prepared_queries = queries + .iter() + .map(|query| comparison.prepare(query)) + .map(Cow::into_owned) + .collect::>(); + if prepared_queries.iter().any(std::string::String::is_empty) { + return Err(MemoriesBackendError::EmptyQuery); + } + Ok(Self { queries, - normalized_queries, + prepared_queries, + comparison, match_mode, + }) + } + + fn matched_query_flags(&self, line: &str) -> Vec { + let line = self.comparison.prepare(line); + self.prepared_queries + .iter() + .map(|query| line.as_ref().contains(query)) + .collect() + } + + fn matched_queries(&self, matched_query_flags: &[bool]) -> Vec { + self.queries + .iter() + .zip(matched_query_flags) + .filter_map(|(query, matched)| matched.then_some(query.clone())) + .collect() + } +} + +#[derive(Clone, Copy)] +struct SearchComparison { + case_sensitive: bool, + normalized: bool, +} + +impl SearchComparison { + fn new(case_sensitive: bool, normalized: bool) -> Self { + Self { + case_sensitive, + normalized, } } - fn matched_queries(&self, line: &str) -> Vec { - let line = match self.normalized_queries.as_ref() { - Some(_) => Cow::Owned(line.to_lowercase()), - None => Cow::Borrowed(line), + fn prepare<'a>(self, value: &'a str) -> Cow<'a, str> { + if self.case_sensitive && !self.normalized { + return Cow::Borrowed(value); + } + + let value = if self.case_sensitive { + Cow::Borrowed(value) + } else { + Cow::Owned(value.to_lowercase()) }; - let queries = self.normalized_queries.as_deref().unwrap_or(&self.queries); - let mut matched_queries = Vec::new(); - for (idx, query) in queries.iter().enumerate() { - if line.as_ref().contains(query) { - matched_queries.push(self.queries[idx].clone()); - } - } - match self.match_mode { - SearchMatchMode::Any => matched_queries, - SearchMatchMode::All if matched_queries.len() == self.queries.len() => matched_queries, - SearchMatchMode::All => Vec::new(), + if !self.normalized { + return value; } + + Cow::Owned( + value + .chars() + .filter(|ch| ch.is_alphanumeric()) + .collect::(), + ) } } @@ -403,6 +558,18 @@ fn reject_symlink(path: &str, metadata: &std::fs::Metadata) -> Result<(), Memori Ok(()) } +fn is_hidden_component(component: Component<'_>) -> bool { + matches!( + component, + Component::Normal(name) if name.to_string_lossy().starts_with('.') + ) +} + +fn is_hidden_path(path: &Path) -> bool { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with('.')) +} + fn display_relative_path(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) diff --git a/codex-rs/memories/mcp/src/local_tests.rs b/codex-rs/memories/mcp/src/local_tests.rs index 51a10ca3b8..a2dbbc0477 100644 --- a/codex-rs/memories/mcp/src/local_tests.rs +++ b/codex-rs/memories/mcp/src/local_tests.rs @@ -16,6 +16,7 @@ fn search_request(queries: &[&str]) -> SearchMemoriesRequest { cursor: None, context_lines: 0, case_sensitive: true, + normalized: false, max_results: DEFAULT_SEARCH_MAX_RESULTS, } } @@ -26,9 +27,15 @@ async fn list_returns_shallow_memory_paths() { tokio::fs::create_dir_all(tempdir.path().join("skills/example")) .await .expect("create skills dir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary") .await .expect("write memory file"); + tokio::fs::write(tempdir.path().join(".DS_Store"), "metadata") + .await + .expect("write hidden file"); tokio::fs::write(tempdir.path().join("skills/example/SKILL.md"), "skill") .await .expect("write skill file"); @@ -193,6 +200,25 @@ async fn list_scoped_directory_is_shallow() { ); } +#[tokio::test] +async fn list_rejects_hidden_scoped_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + + let err = backend(&tempdir) + .list(ListMemoriesRequest { + path: Some(".git".to_string()), + cursor: None, + max_results: DEFAULT_LIST_MAX_RESULTS, + }) + .await + .expect_err("hidden scoped paths should stay invisible"); + + assert!(matches!(err, MemoriesBackendError::NotFound { .. })); +} + #[tokio::test] async fn list_rejects_invalid_cursor() { let tempdir = TempDir::new().expect("tempdir"); @@ -315,6 +341,29 @@ async fn read_supports_line_offset() { ); } +#[tokio::test] +async fn read_rejects_hidden_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + tokio::fs::write(tempdir.path().join(".git/HEAD"), "ref: refs/heads/main\n") + .await + .expect("write hidden file"); + + let err = backend(&tempdir) + .read(ReadMemoryRequest { + path: ".git/HEAD".to_string(), + line_offset: 1, + max_lines: None, + max_tokens: DEFAULT_READ_MAX_TOKENS, + }) + .await + .expect_err("hidden paths should stay invisible"); + + assert!(matches!(err, MemoriesBackendError::NotFound { .. })); +} + #[tokio::test] async fn read_supports_max_lines() { let tempdir = TempDir::new().expect("tempdir"); @@ -559,6 +608,56 @@ async fn search_preserves_global_lexicographic_path_order() { ); } +#[tokio::test] +async fn search_skips_hidden_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle visible\n") + .await + .expect("write visible file"); + tokio::fs::write(tempdir.path().join(".git/HEAD"), "needle hidden\n") + .await + .expect("write hidden file"); + tokio::fs::write(tempdir.path().join(".hidden"), "needle hidden\n") + .await + .expect("write hidden file"); + + let response = backend(&tempdir) + .search(search_request(&["needle"])) + .await + .expect("search memories"); + + assert_eq!( + response.matches, + vec![MemorySearchMatch { + path: "MEMORY.md".to_string(), + match_line_number: 1, + content_start_line_number: 1, + content: "needle visible".to_string(), + matched_queries: vec!["needle".to_string()], + }] + ); +} + +#[tokio::test] +async fn search_rejects_hidden_scoped_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + + let mut request = search_request(&["needle"]); + request.path = Some(".git".to_string()); + let err = backend(&tempdir) + .search(request) + .await + .expect_err("hidden scoped paths should stay invisible"); + + assert!(matches!(err, MemoriesBackendError::NotFound { .. })); +} + #[tokio::test] async fn search_supports_context_lines() { let tempdir = TempDir::new().expect("tempdir"); @@ -654,7 +753,68 @@ async fn search_supports_case_insensitive_matching() { } #[tokio::test] -async fn search_supports_any_and_all_match_modes() { +async fn search_supports_normalized_matching() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::write( + tempdir.path().join("MEMORY.md"), + "MultiAgentV2\ncold-resume\n", + ) + .await + .expect("write memory file"); + + let literal_response = backend(&tempdir) + .search(search_request(&["multi agent v2", "cold resume"])) + .await + .expect("search without normalization"); + assert_eq!(literal_response.matches, Vec::new()); + + let mut request = search_request(&["multi agent v2", "cold resume"]); + request.case_sensitive = false; + request.normalized = true; + let normalized_response = backend(&tempdir) + .search(request) + .await + .expect("search with normalization"); + assert_eq!( + normalized_response.matches, + vec![ + MemorySearchMatch { + path: "MEMORY.md".to_string(), + match_line_number: 1, + content_start_line_number: 1, + content: "MultiAgentV2".to_string(), + matched_queries: vec!["multi agent v2".to_string()], + }, + MemorySearchMatch { + path: "MEMORY.md".to_string(), + match_line_number: 2, + content_start_line_number: 2, + content: "cold-resume".to_string(), + matched_queries: vec!["cold resume".to_string()], + }, + ] + ); +} + +#[tokio::test] +async fn search_rejects_queries_that_normalize_to_empty_strings() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle\n") + .await + .expect("write memory file"); + + let mut request = search_request(&["-"]); + request.normalized = true; + let err = backend(&tempdir) + .search(request) + .await + .expect_err("separator-only normalized queries should be rejected"); + + assert!(matches!(err, MemoriesBackendError::EmptyQuery)); +} + +#[tokio::test] +async fn search_supports_any_and_all_on_same_line_match_modes() { let tempdir = TempDir::new().expect("tempdir"); tokio::fs::write( tempdir.path().join("MEMORY.md"), @@ -695,11 +855,11 @@ async fn search_supports_any_and_all_match_modes() { ); let mut request = search_request(&["alpha", "needle"]); - request.match_mode = SearchMatchMode::All; + request.match_mode = SearchMatchMode::AllOnSameLine; let all_response = backend(&tempdir) .search(request) .await - .expect("search with all match mode"); + .expect("search with all-on-same-line match mode"); assert_eq!( all_response.matches, vec![MemorySearchMatch { @@ -712,6 +872,63 @@ async fn search_supports_any_and_all_match_modes() { ); } +#[tokio::test] +async fn search_supports_all_within_lines_match_mode() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::write( + tempdir.path().join("MEMORY.md"), + "alpha first\nmiddle\nneedle later\nalpha again needle together\n", + ) + .await + .expect("write memory file"); + + let mut request = search_request(&["alpha", "needle"]); + request.match_mode = SearchMatchMode::AllWithinLines { line_count: 3 }; + request.context_lines = 1; + let response = backend(&tempdir) + .search(request) + .await + .expect("search with all-within-lines match mode"); + + assert_eq!( + response.matches, + vec![ + MemorySearchMatch { + path: "MEMORY.md".to_string(), + match_line_number: 1, + content_start_line_number: 1, + content: "alpha first\nmiddle\nneedle later\nalpha again needle together" + .to_string(), + matched_queries: vec!["alpha".to_string(), "needle".to_string()], + }, + MemorySearchMatch { + path: "MEMORY.md".to_string(), + match_line_number: 4, + content_start_line_number: 3, + content: "needle later\nalpha again needle together".to_string(), + matched_queries: vec!["alpha".to_string(), "needle".to_string()], + }, + ] + ); +} + +#[tokio::test] +async fn search_rejects_zero_line_window() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle\n") + .await + .expect("write memory file"); + + let mut request = search_request(&["needle"]); + request.match_mode = SearchMatchMode::AllWithinLines { line_count: 0 }; + let err = backend(&tempdir) + .search(request) + .await + .expect_err("zero-width windows should be rejected"); + + assert!(matches!(err, MemoriesBackendError::InvalidMatchWindow)); +} + #[tokio::test] async fn search_rejects_invalid_cursor() { let tempdir = TempDir::new().expect("tempdir"); diff --git a/codex-rs/memories/mcp/src/server.rs b/codex-rs/memories/mcp/src/server.rs index 25cc7252b9..f25e75d372 100644 --- a/codex-rs/memories/mcp/src/server.rs +++ b/codex-rs/memories/mcp/src/server.rs @@ -74,6 +74,7 @@ struct SearchArgs { #[schemars(range(min = 0))] context_lines: Option, case_sensitive: Option, + normalized: Option, #[schemars(range(min = 1))] max_results: Option, } @@ -227,7 +228,7 @@ fn search_tool() -> Tool { let mut tool = Tool::new( Cow::Borrowed(SEARCH_TOOL_NAME), Cow::Borrowed( - "Search Codex memory files for line-based substring matches, optionally requiring any or all query substrings on the same line.", + "Search Codex memory files for substring matches, optionally normalizing separators or requiring all query substrings on the same line or within a line window.", ), Arc::new(schema::input_schema_for::()), ); @@ -251,6 +252,7 @@ impl SearchArgs { cursor: self.cursor, context_lines: self.context_lines.unwrap_or(0), case_sensitive: self.case_sensitive.unwrap_or(true), + normalized: self.normalized.unwrap_or(false), max_results: clamp_max_results( self.max_results, DEFAULT_SEARCH_MAX_RESULTS, @@ -273,7 +275,10 @@ fn backend_error_to_mcp(err: MemoriesBackendError) -> McpError { | MemoriesBackendError::InvalidMaxLines | MemoriesBackendError::LineOffsetExceedsFileLength | MemoriesBackendError::NotFile { .. } - | MemoriesBackendError::EmptyQuery => McpError::invalid_params(err.to_string(), None), + | MemoriesBackendError::EmptyQuery + | MemoriesBackendError::InvalidMatchWindow => { + McpError::invalid_params(err.to_string(), None) + } MemoriesBackendError::Io(_) => McpError::internal_error(err.to_string(), None), } } @@ -303,6 +308,61 @@ mod tests { cursor: None, context_lines: 0, case_sensitive: false, + normalized: false, + max_results: DEFAULT_SEARCH_MAX_RESULTS, + } + ); + } + + #[test] + fn search_args_accept_windowed_all_match_mode() { + let args: SearchArgs = parse_args(json!({ + "queries": ["alpha", "needle"], + "match_mode": { + "type": "all_within_lines", + "line_count": 3 + } + })) + .expect("windowed all args should parse"); + + let request = args.into_request(); + + assert_eq!( + request, + SearchMemoriesRequest { + queries: vec!["alpha".to_string(), "needle".to_string()], + match_mode: SearchMatchMode::AllWithinLines { line_count: 3 }, + path: None, + cursor: None, + context_lines: 0, + case_sensitive: true, + normalized: false, + max_results: DEFAULT_SEARCH_MAX_RESULTS, + } + ); + } + + #[test] + fn search_args_accept_normalized_matching() { + let args: SearchArgs = parse_args(json!({ + "queries": ["multi agent v2"], + "case_sensitive": false, + "normalized": true + })) + .expect("normalized args should parse"); + + let request = args.into_request(); + + assert_eq!( + request, + SearchMemoriesRequest { + queries: vec!["multi agent v2".to_string()], + match_mode: SearchMatchMode::Any, + path: None, + cursor: None, + context_lines: 0, + case_sensitive: false, + normalized: true, max_results: DEFAULT_SEARCH_MAX_RESULTS, } ); diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 737fb67870..53a10934a5 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -15,15 +15,16 @@ use codex_login::auth_env_telemetry::collect_auth_env_telemetry; use codex_login::default_client::originator; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_rollout_trace::InferenceTraceContext; @@ -44,7 +45,7 @@ pub(crate) struct StageOneRequestContext { pub(crate) session_telemetry: SessionTelemetry, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummary, - pub(crate) service_tier: Option, + pub(crate) service_tier: Option, pub(crate) turn_metadata_header: Option, } @@ -173,6 +174,7 @@ impl MemoryStartupContext { let session_source = self.thread.config_snapshot().await.session_source; let model_client = ModelClient::new( Some(Arc::clone(&self.auth_manager)), + SessionId::from(self.thread_id), self.thread_id, installation_id, config.model_provider.clone(), @@ -191,7 +193,7 @@ impl MemoryStartupContext { &context.session_telemetry, context.reasoning_effort, context.reasoning_summary, - context.service_tier, + context.service_tier.clone(), context.turn_metadata_header.as_deref(), &InferenceTraceContext::disabled(), ) @@ -241,6 +243,7 @@ impl MemoryStartupContext { session_source: Some(SessionSource::Internal( InternalSessionSource::MemoryConsolidation, )), + thread_source: Some(ThreadSource::MemoryConsolidation), dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index d89b68825e..4fcb1d409b 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -253,19 +253,23 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu model: None, effort: None, summary: None, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), collaboration_mode: None, personality: None, }) .await?; - let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast)).await?; - assert_eq!(config_snapshot.service_tier, Some(ServiceTier::Fast)); + let config_snapshot = + wait_for_service_tier(&test, Some(ServiceTier::Fast.request_value().to_string())).await?; + assert_eq!( + config_snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); let context = crate::runtime::MemoryStartupContext::new( Arc::clone(&test.thread_manager), test.thread_manager.auth_manager(), - test.session_configured.session_id, + test.session_configured.thread_id, Arc::clone(&test.codex), &test.config, config_snapshot.session_source.clone(), @@ -277,7 +281,10 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu ReasoningEffort::Low, ) .await; - assert_eq!(request_context.service_tier, Some(ServiceTier::Fast)); + assert_eq!( + request_context.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); shutdown_test_codex(&test).await?; Ok(()) @@ -317,7 +324,7 @@ async fn trigger_memories_startup(test: &TestCodex) { start_memories_startup_task( Arc::clone(&test.thread_manager), test.thread_manager.auth_manager(), - test.session_configured.session_id, + test.session_configured.thread_id, Arc::clone(&test.codex), Arc::new(config), &config_snapshot.session_source, @@ -394,7 +401,7 @@ async fn wait_for_request(mock: &ResponseMock, expected_count: usize) -> Vec, + expected_service_tier: Option, ) -> anyhow::Result { let deadline = Instant::now() + Duration::from_secs(10); loop { diff --git a/codex-rs/message-history/BUILD.bazel b/codex-rs/message-history/BUILD.bazel new file mode 100644 index 0000000000..70df76cf77 --- /dev/null +++ b/codex-rs/message-history/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "message-history", + crate_name = "codex_message_history", +) diff --git a/codex-rs/message-history/Cargo.toml b/codex-rs/message-history/Cargo.toml new file mode 100644 index 0000000000..34bffd687a --- /dev/null +++ b/codex-rs/message-history/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codex-message-history" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_message_history" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-config = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/message-history/src/lib.rs similarity index 90% rename from codex-rs/core/src/message_history.rs rename to codex-rs/message-history/src/lib.rs index 3458ec7306..0d85cb8fe3 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/message-history/src/lib.rs @@ -5,16 +5,14 @@ //! JSON-Lines tooling. Each record has the following schema: //! //! ````text -//! {"conversation_id":"","ts":,"text":""} +//! {"session_id":"","ts":,"text":""} //! ```` //! -//! To minimise the chance of interleaved writes when multiple processes are +//! To minimize the chance of interleaved writes when multiple processes are //! appending concurrently, callers should *prepare the full line* (record + //! trailing `\n`) and write it with a **single `write(2)` system call** while //! the file descriptor is opened with the `O_APPEND` flag. POSIX guarantees //! that writes up to `PIPE_BUF` bytes are atomic in that case. -//! Note: `conversation_id` stores the thread id; the field name is preserved for -//! backwards compatibility with existing history files. use std::fs::File; use std::fs::OpenOptions; @@ -26,6 +24,7 @@ use std::io::Seek; use std::io::SeekFrom; use std::io::Write; use std::path::Path; +use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; @@ -34,11 +33,9 @@ use std::time::Duration; use tokio::fs; use tokio::io::AsyncReadExt; -use crate::config::Config; +use codex_config::types::History; use codex_config::types::HistoryPersistence; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_protocol::ThreadId; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[cfg(unix)] @@ -60,7 +57,24 @@ pub struct HistoryEntry { pub text: String, } -fn history_filepath(config: &Config) -> AbsolutePathBuf { +#[derive(Debug, Clone, PartialEq)] +pub struct HistoryConfig { + pub codex_home: PathBuf, + pub persistence: HistoryPersistence, + pub max_bytes: Option, +} + +impl HistoryConfig { + pub fn new(codex_home: impl Into, history: &History) -> Self { + Self { + codex_home: codex_home.into(), + persistence: history.persistence, + max_bytes: history.max_bytes, + } + } +} + +fn history_filepath(config: &HistoryConfig) -> PathBuf { config.codex_home.join(HISTORY_FILENAME) } @@ -79,8 +93,12 @@ fn history_filepath(config: &Config) -> AbsolutePathBuf { /// Returns an I/O error if the history file cannot be opened/created, the /// system clock is before the Unix epoch, or the exclusive lock cannot be /// acquired after [`MAX_RETRIES`] attempts. -pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Config) -> Result<()> { - match config.history.persistence { +pub async fn append_entry( + text: &str, + conversation_id: impl std::fmt::Display, + config: &HistoryConfig, +) -> Result<()> { + match config.persistence { HistoryPersistence::SaveAll => { // Save everything: proceed. } @@ -128,7 +146,7 @@ pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Confi // Ensure permissions. ensure_owner_only_permissions(&history_file).await?; - let history_max_bytes = config.history.max_bytes; + let history_max_bytes = config.max_bytes; // Perform a blocking write under an advisory write lock using std::fs. tokio::task::spawn_blocking(move || -> Result<()> { @@ -256,7 +274,7 @@ fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 { /// `(0, 0)` when the file does not exist or its metadata cannot be read. If /// metadata succeeds but the file cannot be opened or scanned, returns /// `(log_id, 0)` so callers can still detect that a history file exists. -pub async fn history_metadata(config: &Config) -> (u64, usize) { +pub async fn history_metadata(config: &HistoryConfig) -> (u64, usize) { let path = history_filepath(config); history_metadata_for_file(&path).await } @@ -271,7 +289,7 @@ pub async fn history_metadata(config: &Config) -> (u64, usize) { /// This function is synchronous because it acquires a shared advisory file lock /// via `File::try_lock_shared`. Callers on an async runtime should wrap it in /// `spawn_blocking`. -pub fn lookup(log_id: u64, offset: usize, config: &Config) -> Option { +pub fn lookup(log_id: u64, offset: usize, config: &HistoryConfig) -> Option { let path = history_filepath(config); lookup_history_entry(&path, log_id, offset) } @@ -300,7 +318,7 @@ async fn ensure_owner_only_permissions(_file: &File) -> Result<()> { async fn history_metadata_for_file(path: &Path) -> (u64, usize) { let log_id = match fs::metadata(path).await { - Ok(metadata) => history_log_id(&metadata).unwrap_or(0), + Ok(metadata) => log_identity(&metadata).unwrap_or(0), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0), Err(_) => return (0, 0), }; @@ -347,7 +365,7 @@ fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option Option Option { +fn log_identity(metadata: &std::fs::Metadata) -> Option { use std::os::unix::fs::MetadataExt; Some(metadata.ino()) } #[cfg(windows)] -fn history_log_id(metadata: &std::fs::Metadata) -> Option { +fn log_identity(metadata: &std::fs::Metadata) -> Option { use std::os::windows::fs::MetadataExt; Some(metadata.creation_time()) } #[cfg(not(any(unix, windows)))] -fn history_log_id(_metadata: &std::fs::Metadata) -> Option { +fn log_identity(_metadata: &std::fs::Metadata) -> Option { None } #[cfg(test)] -#[path = "message_history_tests.rs"] mod tests; diff --git a/codex-rs/core/src/message_history_tests.rs b/codex-rs/message-history/src/tests.rs similarity index 88% rename from codex-rs/core/src/message_history_tests.rs rename to codex-rs/message-history/src/tests.rs index de89a3eb9c..88f0b7e007 100644 --- a/codex-rs/core/src/message_history_tests.rs +++ b/codex-rs/message-history/src/tests.rs @@ -1,6 +1,5 @@ use super::*; -use crate::config::ConfigBuilder; -use codex_protocol::ThreadId; +use codex_config::types::History; use pretty_assertions::assert_eq; use std::fs::File; use std::io::Write; @@ -88,14 +87,9 @@ async fn lookup_uses_stable_log_id_after_appends() { #[tokio::test] async fn append_entry_trims_history_when_beyond_max_bytes() { let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); + let mut history = History::default(); + let mut config = HistoryConfig::new(codex_home.path(), &history); + let conversation_id = "conversation-id"; let entry_one = "a".repeat(200); let entry_two = "b".repeat(200); @@ -109,8 +103,8 @@ async fn append_entry_trims_history_when_beyond_max_bytes() { let first_len = std::fs::metadata(&history_path).expect("metadata").len(); let limit_bytes = first_len + 10; - config.history.max_bytes = - Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); + history.max_bytes = Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); + config = HistoryConfig::new(codex_home.path(), &history); append_entry(&entry_two, &conversation_id, &config) .await @@ -135,14 +129,9 @@ async fn append_entry_trims_history_when_beyond_max_bytes() { #[tokio::test] async fn append_entry_trims_history_to_soft_cap() { let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); + let mut history = History::default(); + let mut config = HistoryConfig::new(codex_home.path(), &history); + let conversation_id = "conversation-id"; let short_entry = "a".repeat(200); let long_entry = "b".repeat(400); @@ -165,10 +154,11 @@ async fn append_entry_trims_history_to_soft_cap() { .checked_sub(short_entry_len) .expect("second entry length should be larger than first entry length"); - config.history.max_bytes = Some( + history.max_bytes = Some( usize::try_from((2 * long_entry_len) + (short_entry_len / 2)) .expect("max bytes should fit into usize"), ); + config = HistoryConfig::new(codex_home.path(), &history); append_entry(&long_entry, &conversation_id, &config) .await @@ -185,10 +175,7 @@ async fn append_entry_trims_history_to_soft_cap() { assert_eq!(entries[0].text, long_entry); let pruned_len = std::fs::metadata(&history_path).expect("metadata").len(); - let max_bytes = config - .history - .max_bytes - .expect("max bytes should be configured") as u64; + let max_bytes = config.max_bytes.expect("max bytes should be configured") as u64; assert!(pruned_len <= max_bytes); diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 4ca2cb891e..60fa17d368 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -47,6 +47,7 @@ fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { supported_in_api: true, priority, additional_speed_tiers: vec!["fast".to_string()], + service_tiers: Vec::new(), availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), @@ -87,6 +88,7 @@ fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo supported_in_api: true, priority, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index 5859830310..517bb0abbb 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -421,15 +421,16 @@ fn find_model_by_longest_prefix(model: &str, candidates: &[ModelInfo]) -> Option fn find_model_by_namespaced_suffix(model: &str, candidates: &[ModelInfo]) -> Option { // Retry metadata lookup for a single namespaced slug like `namespace/model-name`. // - // This only strips one leading namespace segment and only when the namespace is ASCII - // alphanumeric/underscore (`\w+`) to avoid broadly matching arbitrary aliases. + // This only strips one leading namespace segment and only when the namespace looks + // like a simple provider id to avoid broadly matching arbitrary aliases. let (namespace, suffix) = model.split_once('/')?; if suffix.contains('/') { return None; } - if !namespace - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_') + if namespace.is_empty() + || !namespace + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return None; } diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index 24ae9f3591..9df9fb09c9 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -295,6 +295,21 @@ async fn get_model_info_matches_namespaced_suffix() { assert!(!model_info.used_fallback_model_metadata); } +#[tokio::test] +async fn get_model_info_matches_hyphenated_provider_namespace_suffix() { + let config = ModelsManagerConfig::default(); + let remote = remote_model("gpt-image", "Image", /*priority*/ 0); + let manager = static_manager_for_tests(ModelsResponse { + models: vec![remote], + }); + let namespaced_model = "openai-codex/gpt-image".to_string(); + + let model_info = manager.get_model_info(&namespaced_model, &config).await; + + assert_eq!(model_info.slug, namespaced_model); + assert!(!model_info.used_fallback_model_metadata); +} + #[tokio::test] async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() { let codex_home = tempdir().expect("temp dir"); diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 8e8abae549..774dd3eaca 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -76,6 +76,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { supported_in_api: true, priority: 99, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), availability_nux: None, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 658ee9f610..fd42fc92e0 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -75,6 +75,7 @@ use std::convert::Infallible; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; use std::sync::Arc; +use std::time::Instant; use tracing::error; use tracing::info; use tracing::warn; @@ -370,6 +371,16 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { } else { None }; + match proxy.as_ref() { + Some(proxy) => info!( + "CONNECT route selected (host={}, port={}, route=upstream_proxy, proxy={})", + target.host, target.port, proxy.address + ), + None => info!( + "CONNECT route selected (host={}, port={}, route=direct)", + target.host, target.port + ), + } if let Err(err) = forward_connect_tunnel(upgraded, proxy, app_state).await { warn!("tunnel error: {err}"); @@ -402,21 +413,47 @@ async fn forward_connect_tunnel( let connector = TlsConnectorLayer::tunnel(None) .with_connector_data(tls_config) .into_layer(proxy_connector); - let EstablishedClientConnection { conn: target, .. } = - connector.connect(req).await.map_err(|err| { - OpaqueError::from_boxed(err) + info!("CONNECT upstream dial started (target={authority})"); + let connect_started_at = Instant::now(); + let EstablishedClientConnection { conn: target, .. } = match connector.connect(req).await { + Ok(connection) => { + info!( + "CONNECT upstream dial established (target={authority}, elapsed_ms={})", + connect_started_at.elapsed().as_millis() + ); + connection + } + Err(err) => { + warn!( + "CONNECT upstream dial failed (target={authority}, elapsed_ms={})", + connect_started_at.elapsed().as_millis() + ); + return Err(OpaqueError::from_boxed(err) .with_context(|| format!("establish CONNECT tunnel to {authority}")) - .into_boxed() - })?; + .into_boxed()); + } + }; let proxy_req = ProxyRequest { source: upgraded, target, }; + info!("CONNECT tunnel forwarding started (target={authority})"); + let forward_started_at = Instant::now(); StreamForwardService::default() .serve(proxy_req) .await + .map(|_| { + info!( + "CONNECT tunnel forwarding completed (target={authority}, elapsed_ms={})", + forward_started_at.elapsed().as_millis() + ); + }) .map_err(|err| { + warn!( + "CONNECT tunnel forwarding failed (target={authority}, elapsed_ms={})", + forward_started_at.elapsed().as_millis() + ); OpaqueError::from_boxed(err.into()) .with_context(|| format!("forward CONNECT tunnel to {authority}")) .into_boxed() diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 984236d892..11e3804baf 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -25,6 +25,7 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::future::Future; use std::net::IpAddr; +use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -401,7 +402,18 @@ impl NetworkProxyState { if !is_explicit_local_allowlisted(&allowed_domains, &host) { return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); } - } else if host_resolves_to_non_public_ip(host_str, port).await { + } else if host_resolves_to_non_public_ip( + host_str, + port, + DNS_LOOKUP_TIMEOUT, + |host, port| async move { + lookup_host((host.as_str(), port)) + .await + .map(Iterator::collect) + }, + ) + .await + { return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); } } @@ -714,14 +726,23 @@ pub(crate) fn unix_socket_permissions_supported() -> bool { cfg!(target_os = "macos") } -async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool { +async fn host_resolves_to_non_public_ip( + host: &str, + port: u16, + lookup_timeout: Duration, + lookup: F, +) -> bool +where + F: FnOnce(String, u16) -> Fut, + Fut: Future>>, +{ if let Ok(ip) = host.parse::() { return is_non_public_ip(ip); } // Block the request if this DNS lookup fails. We resolve the hostname again when we connect, // so a failed check here does not prove the destination is public. - let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await { + let addrs = match timeout(lookup_timeout, lookup(host.to_string(), port)).await { Ok(Ok(addrs)) => addrs, Ok(Err(err)) => { debug!( @@ -1360,6 +1381,65 @@ mod tests { ); } + #[tokio::test] + async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_timeout() { + let blocked = host_resolves_to_non_public_ip( + "slow.example", + /*port*/ 80, + Duration::from_millis(1), + |_host, _port| async { + std::future::pending::>>().await + }, + ) + .await; + + assert!(blocked); + } + + #[tokio::test] + async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_error() { + let blocked = host_resolves_to_non_public_ip( + "error.example", + /*port*/ 80, + Duration::from_millis(10), + |_host, _port| async { + Err::, std::io::Error>(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "forced failure", + )) + }, + ) + .await; + + assert!(blocked); + } + + #[tokio::test] + async fn host_resolves_to_non_public_ip_blocks_private_resolution() { + let blocked = host_resolves_to_non_public_ip( + "local.example", + /*port*/ 80, + Duration::from_millis(10), + |_host, _port| async { Ok(vec!["127.0.0.1:80".parse().unwrap()]) }, + ) + .await; + + assert!(blocked); + } + + #[tokio::test] + async fn host_resolves_to_non_public_ip_allows_public_resolution() { + let blocked = host_resolves_to_non_public_ip( + "public.example", + /*port*/ 80, + Duration::from_millis(10), + |_host, _port| async { Ok(vec!["8.8.8.8:80".parse().unwrap()]) }, + ) + .await; + + assert!(!blocked); + } + #[test] fn validate_policy_against_constraints_disallows_widening_allowed_domains() { let constraints = NetworkProxyConstraints { diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 2d4c05f95c..a1c430c7db 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -40,6 +40,7 @@ use std::io; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; use std::sync::Arc; +use std::time::Instant; use tracing::error; use tracing::info; use tracing::warn; @@ -290,7 +291,20 @@ async fn handle_socks5_tcp( } } - tcp_connector.serve(req).await + info!("SOCKS upstream dial started (host={host}, port={port})"); + let connect_started_at = Instant::now(); + let result = tcp_connector.serve(req).await; + match &result { + Ok(_) => info!( + "SOCKS upstream dial established (host={host}, port={port}, elapsed_ms={})", + connect_started_at.elapsed().as_millis() + ), + Err(_) => warn!( + "SOCKS upstream dial failed (host={host}, port={port}, elapsed_ms={})", + connect_started_at.elapsed().as_millis() + ), + } + result } async fn inspect_socks5_udp( diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index c7b67cc18a..72b7290f12 100644 --- a/codex-rs/network-proxy/src/upstream.rs +++ b/codex-rs/network-proxy/src/upstream.rs @@ -3,7 +3,7 @@ use crate::state::NetworkProxyState; use rama_core::Layer; use rama_core::Service; use rama_core::error::BoxError; -use rama_core::error::ErrorContext as _; +use rama_core::error::ErrorExt as _; use rama_core::error::OpaqueError; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; @@ -21,6 +21,8 @@ use rama_net::http::RequestContext; use rama_tls_rustls::client::TlsConnectorDataBuilder; use rama_tls_rustls::client::TlsConnectorLayer; use std::sync::Arc; +use std::time::Instant; +use tracing::info; use tracing::warn; #[cfg(target_os = "macos")] @@ -41,13 +43,6 @@ impl ProxyConfig { Self { http, https, all } } - fn proxy_for_request(&self, req: &Request) -> Option { - let is_secure = RequestContext::try_from(req) - .map(|ctx| ctx.protocol.is_secure()) - .unwrap_or(false); - self.proxy_for_protocol(is_secure) - } - fn proxy_for_protocol(&self, is_secure: bool) -> Option { if is_secure { self.https @@ -155,28 +150,71 @@ impl Service> for UpstreamClient { type Error = OpaqueError; async fn serve(&self, mut req: Request) -> Result { - if let Some(proxy) = self.proxy_config.proxy_for_request(&req) { + let request_context = RequestContext::try_from(&req).ok(); + let authority = request_context + .as_ref() + .map(|ctx| ctx.host_with_port().to_string()) + .unwrap_or_else(|| "".to_string()); + let proxy = self.proxy_config.proxy_for_protocol( + request_context + .as_ref() + .map(|ctx| ctx.protocol.is_secure()) + .unwrap_or(false), + ); + match proxy.as_ref() { + Some(proxy) => info!( + "HTTP upstream route selected (target={authority}, route=upstream_proxy, proxy={})", + proxy.address + ), + None => info!("HTTP upstream route selected (target={authority}, route=direct)"), + } + if let Some(proxy) = proxy { req.extensions_mut().insert(proxy); } let uri = req.uri().clone(); + let connect_started_at = Instant::now(); let EstablishedClientConnection { input: mut req, conn: http_connection, - } = self - .connector - .serve(req) - .await - .map_err(OpaqueError::from_boxed)?; + } = match self.connector.serve(req).await { + Ok(connection) => { + info!( + "HTTP upstream connection established (target={authority}, elapsed_ms={})", + connect_started_at.elapsed().as_millis() + ); + connection + } + Err(err) => { + warn!( + "HTTP upstream connection failed (target={authority}, elapsed_ms={})", + connect_started_at.elapsed().as_millis() + ); + return Err(OpaqueError::from_boxed(err)); + } + }; req.extensions_mut() .extend(http_connection.extensions().clone()); - http_connection - .serve(req) - .await - .map_err(OpaqueError::from_boxed) - .with_context(|| format!("http request failure for uri: {uri}")) + let request_started_at = Instant::now(); + match http_connection.serve(req).await { + Ok(resp) => { + info!( + "HTTP upstream response headers received (target={authority}, elapsed_ms={})", + request_started_at.elapsed().as_millis() + ); + Ok(resp) + } + Err(err) => { + warn!( + "HTTP upstream response headers failed (target={authority}, elapsed_ms={})", + request_started_at.elapsed().as_millis() + ); + Err(OpaqueError::from_boxed(err) + .context(format!("http request failure for uri: {uri}"))) + } + } } } diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index aca120f1e1..b2f2d639ce 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -27,13 +27,16 @@ pub const TURN_NETWORK_PROXY_METRIC: &str = "codex.turn.network_proxy"; pub const TURN_MEMORY_METRIC: &str = "codex.turn.memory"; pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call"; pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage"; +pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; +pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; +pub const GOAL_BUDGET_LIMITED_METRIC: &str = "codex.goal.budget_limited"; +pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; +pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_sync"; pub const CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC: &str = "codex.plugins.startup_sync.final"; pub const HOOK_RUN_METRIC: &str = "codex.hooks.run"; pub const HOOK_RUN_DURATION_METRIC: &str = "codex.hooks.run.duration_ms"; -pub const LEGACY_NOTIFY_CONFIGURED_METRIC: &str = "codex.notify.configured"; -pub const LEGACY_NOTIFY_RUN_METRIC: &str = "codex.notify.run"; /// Total runtime of a startup prewarm attempt until it completes, tagged by final status. pub const STARTUP_PREWARM_DURATION_METRIC: &str = "codex.startup_prewarm.duration_ms"; /// Age of the startup prewarm attempt when the first real turn resolves it, tagged by outcome. diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 0865b9020f..c76697366f 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::collections::HashSet; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; use crate::AppConnectorId; use crate::PluginCapabilitySummary; @@ -116,6 +117,24 @@ impl PluginLoadOutcome { skill_roots } + pub fn effective_plugin_skill_roots(&self) -> Vec { + let mut skill_roots = Vec::new(); + let mut seen_paths = HashSet::new(); + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for path in &plugin.skill_roots { + if seen_paths.insert(path.clone()) { + skill_roots.push(PluginSkillRoot { + path: path.clone(), + plugin_id: plugin.config_name.clone(), + }); + } + } + } + + skill_roots.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + skill_roots + } + pub fn effective_mcp_servers(&self) -> HashMap { let mut mcp_servers = HashMap::new(); for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { @@ -172,10 +191,61 @@ impl PluginLoadOutcome { /// without naming the MCP config type parameter. pub trait EffectiveSkillRoots { fn effective_skill_roots(&self) -> Vec; + + fn effective_plugin_skill_roots(&self) -> Vec; } impl EffectiveSkillRoots for PluginLoadOutcome { fn effective_skill_roots(&self) -> Vec { PluginLoadOutcome::effective_skill_roots(self) } + + fn effective_plugin_skill_roots(&self) -> Vec { + PluginLoadOutcome::effective_plugin_skill_roots(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_path(name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path_checked(std::env::temp_dir().join(name)) + .expect("absolute temp path") + } + + fn loaded_plugin(config_name: &str, skill_roots: Vec) -> LoadedPlugin<()> { + LoadedPlugin { + config_name: config_name.to_string(), + manifest_name: None, + manifest_description: None, + root: test_path(config_name), + enabled: true, + skill_roots, + disabled_skill_paths: HashSet::new(), + has_enabled_skills: true, + mcp_servers: HashMap::new(), + apps: Vec::new(), + hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), + error: None, + } + } + + #[test] + fn effective_plugin_skill_roots_preserves_first_plugin_for_shared_root() { + let shared_root = test_path("shared-skills"); + let outcome = PluginLoadOutcome::from_plugins(vec![ + loaded_plugin("zeta@test", vec![shared_root.clone()]), + loaded_plugin("alpha@test", vec![shared_root.clone()]), + ]); + + assert_eq!( + outcome.effective_plugin_skill_roots(), + vec![PluginSkillRoot { + path: shared_root, + plugin_id: "zeta@test".to_string(), + }] + ); + } } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index da83ee858a..47dc15f183 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -355,6 +355,23 @@ pub enum ServiceTier { Flex, } +impl ServiceTier { + pub const fn request_value(self) -> &'static str { + match self { + Self::Fast => "priority", + Self::Flex => "flex", + } + } + + pub fn from_request_value(value: &str) -> Option { + match value { + "fast" | "priority" => Some(Self::Fast), + "flex" => Some(Self::Flex), + _ => None, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 175c92331f..63053159c6 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,9 +1,11 @@ pub mod account; mod agent_path; pub mod auth; +mod session_id; mod thread_id; mod tool_name; pub use agent_path::AgentPath; +pub use session_id::SessionId; pub use thread_id::ThreadId; pub use tool_name::ToolName; pub mod approvals; @@ -13,8 +15,8 @@ pub mod error; pub mod exec_output; pub mod items; pub mod mcp; +pub mod mcp_approval_meta; pub mod memory_citation; -pub mod message_history; pub mod models; pub mod network_policy; pub mod num_format; diff --git a/codex-rs/protocol/src/mcp_approval_meta.rs b/codex-rs/protocol/src/mcp_approval_meta.rs new file mode 100644 index 0000000000..7a8695a9b6 --- /dev/null +++ b/codex-rs/protocol/src/mcp_approval_meta.rs @@ -0,0 +1,19 @@ +pub const APPROVAL_KIND_KEY: &str = "codex_approval_kind"; +pub const APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; +pub const APPROVAL_KIND_TOOL_SUGGESTION: &str = "tool_suggestion"; +pub const REQUEST_TYPE_KEY: &str = "codex_request_type"; +pub const REQUEST_TYPE_APPROVAL_REQUEST: &str = "approval_request"; +pub const APPROVALS_REVIEWER_KEY: &str = "approvals_reviewer"; +pub const PERSIST_KEY: &str = "persist"; +pub const PERSIST_SESSION: &str = "session"; +pub const PERSIST_ALWAYS: &str = "always"; +pub const SOURCE_KEY: &str = "source"; +pub const SOURCE_CONNECTOR: &str = "connector"; +pub const CONNECTOR_ID_KEY: &str = "connector_id"; +pub const CONNECTOR_NAME_KEY: &str = "connector_name"; +pub const CONNECTOR_DESCRIPTION_KEY: &str = "connector_description"; +pub const TOOL_NAME_KEY: &str = "tool_name"; +pub const TOOL_TITLE_KEY: &str = "tool_title"; +pub const TOOL_DESCRIPTION_KEY: &str = "tool_description"; +pub const TOOL_PARAMS_KEY: &str = "tool_params"; +pub const TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs deleted file mode 100644 index 0d8bd8df4e..0000000000 --- a/codex-rs/protocol/src/message_history.rs +++ /dev/null @@ -1,11 +0,0 @@ -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use ts_rs::TS; - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -pub struct HistoryEntry { - pub conversation_id: String, - pub ts: u64, - pub text: String, -} diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 41275e6a6b..70d241677c 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -115,6 +115,13 @@ pub struct ModelAvailabilityNux { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +pub struct ModelServiceTier { + pub id: String, + pub name: String, + pub description: String, +} + /// Metadata describing a Codex-supported model. #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ModelPreset { @@ -133,9 +140,12 @@ pub struct ModelPreset { /// Whether this model supports personality-specific instructions. #[serde(default)] pub supports_personality: bool, - /// Additional speed tiers this model can run with beyond the standard path. + /// Deprecated: use `service_tiers` instead. #[serde(default)] pub additional_speed_tiers: Vec, + /// Service tiers this model can run with. + #[serde(default)] + pub service_tiers: Vec, /// Whether this is the default model for new users. pub is_default: bool, /// recommended upgrade model @@ -258,6 +268,8 @@ pub struct ModelInfo { pub priority: i32, #[serde(default)] pub additional_speed_tiers: Vec, + #[serde(default)] + pub service_tiers: Vec, pub availability_nux: Option, pub upgrade: Option, pub base_instructions: String, @@ -442,6 +454,7 @@ impl From for ModelPreset { supported_reasoning_efforts: info.supported_reasoning_levels.clone(), supports_personality, additional_speed_tiers: info.additional_speed_tiers, + service_tiers: info.service_tiers, is_default: false, // default is the highest priority available model upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { id: upgrade.model.clone(), @@ -464,9 +477,13 @@ impl From for ModelPreset { impl ModelPreset { pub fn supports_fast_mode(&self) -> bool { - self.additional_speed_tiers + self.service_tiers .iter() - .any(|tier| tier == SPEED_TIER_FAST) + .any(|tier| tier.id == SPEED_TIER_FAST) + || self + .additional_speed_tiers + .iter() + .any(|tier| tier == SPEED_TIER_FAST) } /// Filter models based on authentication mode. @@ -548,6 +565,7 @@ mod tests { supported_in_api: true, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), availability_nux: None, upgrade: None, base_instructions: "base".to_string(), @@ -818,6 +836,7 @@ mod tests { message: "Try Spark.".to_string(), }), additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()], + service_tiers: Vec::new(), ..test_model(/*spec*/ None) }); @@ -829,4 +848,18 @@ mod tests { ); assert!(preset.supports_fast_mode()); } + + #[test] + fn model_preset_supports_fast_mode_from_service_tiers() { + let preset = ModelPreset::from(ModelInfo { + service_tiers: vec![ModelServiceTier { + id: SPEED_TIER_FAST.to_string(), + name: "Fast".to_string(), + description: "Priority processing.".to_string(), + }], + ..test_model(/*spec*/ None) + }); + + assert!(preset.supports_fast_mode()); + } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a84685cb91..4eb1b8804f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -14,6 +14,7 @@ use std::time::Duration; use strum_macros::EnumIter; use crate::AgentPath; +use crate::SessionId; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; use crate::config_types::ApprovalsReviewer; @@ -21,7 +22,6 @@ use crate::config_types::CollaborationMode; use crate::config_types::ModeKind; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; -use crate::config_types::ServiceTier; use crate::config_types::WindowsSandboxLevel; use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::dynamic_tools::DynamicToolCallRequest; @@ -30,11 +30,7 @@ use crate::dynamic_tools::DynamicToolSpec; use crate::items::TurnItem; use crate::mcp::CallToolResult; use crate::mcp::RequestId; -use crate::mcp::Resource as McpResource; -use crate::mcp::ResourceTemplate as McpResourceTemplate; -use crate::mcp::Tool as McpTool; use crate::memory_citation::MemoryCitation; -use crate::message_history::HistoryEntry; use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; use crate::models::ContentItem; @@ -513,7 +509,7 @@ pub enum Op { /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. @@ -574,7 +570,7 @@ pub enum Op { /// explicitly clear the tier for this turn, or `None` to keep the existing /// session preference. #[serde(default, skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, // The JSON schema to use for the final assistant message final_output_json_schema: Option, @@ -651,7 +647,7 @@ pub enum Op { /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. @@ -723,22 +719,6 @@ pub enum Op { response: DynamicToolResponse, }, - /// Append an entry to the persistent cross-session message history. - /// - /// Note the entry is not guaranteed to be logged if the user has - /// history disabled, it matches the list of "sensitive" patterns, etc. - AddToHistory { - /// The message text to be stored. - text: String, - }, - - /// Request a single history entry identified by `log_id` + `offset`. - GetHistoryEntryRequest { offset: usize, log_id: u64 }, - - /// Request the list of MCP tools available across all configured servers. - /// Reply is delivered via `EventMsg::McpListToolsResponse`. - ListMcpTools, - /// Request MCP servers to reinitialize and refresh cached tool lists. RefreshMcpServers { config: McpServerRefreshConfig }, @@ -748,29 +728,11 @@ pub enum Op { /// enable/disable state) without restarting the thread. ReloadUserConfig, - /// Request the list of skills for the provided `cwd` values or the session default. - ListSkills { - /// Working directories to scope repo skills discovery. - /// - /// When empty, the session default working directory is used. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - cwds: Vec, - - /// When true, recompute skills even if a cached result exists. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - force_reload: bool, - }, - /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. Compact, - /// Set a user-facing thread name in the persisted rollout metadata. - /// This is a local-only operation handled by codex-core; it does not - /// involve the model. - SetThreadName { name: String }, - /// Set whether the thread remains eligible for memory generation. /// /// This persists thread-level memory mode metadata without involving the @@ -801,9 +763,6 @@ pub enum Op { /// The raw command string after '!' command: String, }, - - /// Request the list of available models. - ListModels, } #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)] @@ -896,21 +855,15 @@ impl Op { Self::UserInputAnswer { .. } => "user_input_answer", Self::RequestPermissionsResponse { .. } => "request_permissions_response", Self::DynamicToolResponse { .. } => "dynamic_tool_response", - Self::AddToHistory { .. } => "add_to_history", - Self::GetHistoryEntryRequest { .. } => "get_history_entry_request", - Self::ListMcpTools => "list_mcp_tools", Self::RefreshMcpServers { .. } => "refresh_mcp_servers", Self::ReloadUserConfig => "reload_user_config", - Self::ListSkills { .. } => "list_skills", Self::Compact => "compact", - Self::SetThreadName { .. } => "set_thread_name", Self::SetThreadMemoryMode { .. } => "set_thread_memory_mode", Self::ThreadRollback { .. } => "thread_rollback", Self::Review { .. } => "review", Self::ApproveGuardianDeniedAction { .. } => "approve_guardian_denied_action", Self::Shutdown => "shutdown", Self::RunUserShellCommand { .. } => "run_user_shell_command", - Self::ListModels => "list_models", } } } @@ -1373,9 +1326,6 @@ pub enum EventMsg { /// Ack the client's configure message. SessionConfigured(SessionConfiguredEvent), - /// Updated session metadata (e.g., thread name changes). - ThreadNameUpdated(ThreadNameUpdatedEvent), - /// Updated long-running goal metadata for the thread. ThreadGoalUpdated(ThreadGoalUpdatedEvent), @@ -1448,15 +1398,6 @@ pub enum EventMsg { TurnDiff(TurnDiffEvent), - /// Response to GetHistoryEntryRequest. - GetHistoryEntryResponse(GetHistoryEntryResponseEvent), - - /// List of MCP tools available to the agent. - McpListToolsResponse(McpListToolsResponseEvent), - - /// List of skills available to the agent. - ListSkillsResponse(ListSkillsResponseEvent), - /// List of voices supported by realtime conversation streams. RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent), @@ -1559,19 +1500,13 @@ pub enum HookSource { Unknown, } -impl HookSource { - /// Returns whether hooks from this source are managed and therefore not - /// user-configurable. - pub fn is_managed(self) -> bool { - matches!( - self, - Self::System - | Self::Mdm - | Self::CloudRequirements - | Self::LegacyManagedConfigFile - | Self::LegacyManagedConfigMdm - ) - } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum HookTrustStatus { + Managed, + Untrusted, + Trusted, + Modified, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] @@ -2539,6 +2474,18 @@ impl InitialHistory { }), } } + + pub fn get_resumed_thread_source(&self) -> Option { + match self { + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.thread_source, + _ => None, + }) + } + } + } } fn session_cwd_from_items(items: &[RolloutItem]) -> Option { @@ -2564,6 +2511,44 @@ pub enum SessionSource { Unknown, } +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ThreadSource { + User, + Subagent, + MemoryConsolidation, +} + +impl ThreadSource { + pub fn as_str(self) -> &'static str { + match self { + ThreadSource::User => "user", + ThreadSource::Subagent => "subagent", + ThreadSource::MemoryConsolidation => "memory_consolidation", + } + } +} + +impl fmt::Display for ThreadSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ThreadSource { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "user" => Ok(ThreadSource::User), + "subagent" => Ok(ThreadSource::Subagent), + "memory_consolidation" => Ok(ThreadSource::MemoryConsolidation), + other => Err(format!("unknown thread source: {other}")), + } + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -2624,16 +2609,6 @@ impl SessionSource { }) } - /// Low cardinality thread source label for analytics. - pub fn thread_source_name(&self) -> Option<&'static str> { - match self { - SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), - SessionSource::Internal(_) => Some("internal"), - SessionSource::SubAgent(_) => Some("subagent"), - SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None, - } - } - pub fn is_internal(&self) -> bool { matches!(self, SessionSource::Internal(_)) } @@ -2734,6 +2709,9 @@ pub struct SessionMeta { pub cli_version: String, #[serde(default)] pub source: SessionSource, + /// Optional analytics source classification for this thread. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_source: Option, /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. #[serde(skip_serializing_if = "Option::is_none")] pub agent_nickname: Option, @@ -2764,6 +2742,7 @@ impl Default for SessionMeta { originator: String::new(), cli_version: String::new(), source: SessionSource::default(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, @@ -3257,27 +3236,6 @@ pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct GetHistoryEntryResponseEvent { - pub offset: usize, - pub log_id: u64, - /// The entry at the requested offset, if available and parseable. - #[serde(skip_serializing_if = "Option::is_none")] - pub entry: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct McpListToolsResponseEvent { - /// Fully qualified tool name -> tool definition. - pub tools: std::collections::HashMap, - /// Known resources grouped by server name. - pub resources: std::collections::HashMap>, - /// Known resource templates grouped by server name. - pub resource_templates: std::collections::HashMap>, - /// Authentication status for each configured MCP server. - pub auth_statuses: std::collections::HashMap, -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpStartupUpdateEvent { /// Server name being started. @@ -3331,12 +3289,6 @@ impl fmt::Display for McpAuthStatus { } } -/// Response payload for `Op::ListSkills`. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ListSkillsResponseEvent { - pub skills: Vec, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RealtimeConversationListVoicesResponseEvent { pub voices: RealtimeVoicesList, @@ -3446,19 +3398,6 @@ pub struct SkillToolDependency { pub url: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct SkillErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct SkillsListEntry { - pub cwd: PathBuf, - pub skills: Vec, - pub errors: Vec, -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)] pub struct SessionNetworkProxyRuntime { pub http_addr: String, @@ -3467,9 +3406,13 @@ pub struct SessionNetworkProxyRuntime { #[derive(Debug, Clone, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { - pub session_id: ThreadId, + pub session_id: SessionId, + pub thread_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, + /// Optional analytics source classification for this thread. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_source: Option, /// Optional user-facing thread name (may be unset). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3482,7 +3425,7 @@ pub struct SessionConfiguredEvent { pub model_provider_id: String, #[serde(skip_serializing_if = "Option::is_none")] - pub service_tier: Option, + pub service_tier: Option, /// When to escalate for approval for execution pub approval_policy: AskForApproval, @@ -3510,12 +3453,6 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, - /// Identifier of the history log file (inode on Unix, 0 otherwise). - pub history_log_id: u64, - - /// Current number of entries in the history log. - pub history_entry_count: usize, - /// Optional initial messages (as events) for resumed sessions. /// When present, UIs can use these to seed the history. #[serde(skip_serializing_if = "Option::is_none")] @@ -3538,13 +3475,17 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { { #[derive(Deserialize)] struct Wire { - session_id: ThreadId, + session_id: SessionId, + #[serde(default)] + thread_id: Option, forked_from_id: Option, #[serde(default)] + thread_source: Option, + #[serde(default)] thread_name: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, #[serde(default)] approvals_reviewer: ApprovalsReviewer, @@ -3557,8 +3498,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { active_permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, - history_log_id: u64, - history_entry_count: usize, initial_messages: Option>, network_proxy: Option, rollout_path: Option, @@ -3578,7 +3517,9 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { Ok(Self { session_id: wire.session_id, + thread_id: wire.thread_id.unwrap_or_else(|| wire.session_id.into()), forked_from_id: wire.forked_from_id, + thread_source: wire.thread_source, thread_name: wire.thread_name, model: wire.model, model_provider_id: wire.model_provider_id, @@ -3589,8 +3530,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { active_permission_profile: wire.active_permission_profile, cwd: wire.cwd, reasoning_effort: wire.reasoning_effort, - history_log_id: wire.history_log_id, - history_entry_count: wire.history_entry_count, initial_messages: wire.initial_messages, network_proxy: wire.network_proxy, rollout_path: wire.rollout_path, @@ -3598,14 +3537,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { } } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ThreadNameUpdatedEvent { - pub thread_id: ThreadId, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub thread_name: Option, -} - #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "protocol/")] @@ -3996,20 +3927,6 @@ mod tests { use tempfile::NamedTempFile; use tempfile::TempDir; - #[test] - fn hook_source_managedness_is_source_derived() { - assert_eq!(HookSource::System.is_managed(), true); - assert_eq!(HookSource::Mdm.is_managed(), true); - assert_eq!(HookSource::CloudRequirements.is_managed(), true); - assert_eq!(HookSource::LegacyManagedConfigFile.is_managed(), true); - assert_eq!(HookSource::LegacyManagedConfigMdm.is_managed(), true); - assert_eq!(HookSource::User.is_managed(), false); - assert_eq!(HookSource::Project.is_managed(), false); - assert_eq!(HookSource::SessionFlags.is_managed(), false); - assert_eq!(HookSource::Plugin.is_managed(), false); - assert_eq!(HookSource::Unknown.is_managed(), false); - } - fn sorted_writable_roots(roots: Vec) -> Vec<(PathBuf, Vec)> { let mut sorted_roots: Vec<(PathBuf, Vec)> = roots .into_iter() @@ -4088,28 +4005,6 @@ mod tests { ); } - #[test] - fn session_source_thread_source_name_classifies_user_and_subagent_sources() { - for (source, expected) in [ - (SessionSource::Cli, Some("user")), - (SessionSource::VSCode, Some("user")), - (SessionSource::Exec, Some("user")), - ( - SessionSource::Internal(InternalSessionSource::MemoryConsolidation), - Some("internal"), - ), - ( - SessionSource::SubAgent(SubAgentSource::Review), - Some("subagent"), - ), - (SessionSource::Mcp, None), - (SessionSource::Custom("atlas".to_string()), None), - (SessionSource::Unknown, None), - ] { - assert_eq!(source.thread_source_name(), expected); - } - } - #[test] fn session_source_restriction_product_defaults_non_subagent_sources_to_codex() { assert_eq!( @@ -5343,14 +5238,17 @@ mod tests { /// amount of nesting. #[test] fn serialize_event() -> Result<()> { - let conversation_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let session_id = SessionId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c7")?; + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let rollout_file = NamedTempFile::new()?; let permission_profile = PermissionProfile::read_only(); let event = Event { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: conversation_id, + session_id, + thread_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), @@ -5361,8 +5259,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -5373,7 +5269,8 @@ mod tests { "id": "1234", "msg": { "type": "session_configured", - "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c7", + "thread_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "codex-mini-latest", "model_provider_id": "openai", "approval_policy": "never", @@ -5381,8 +5278,6 @@ mod tests { "permission_profile": permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": "medium", - "history_log_id": 0, - "history_entry_count": 0, "rollout_path": format!("{}", rollout_file.path().display()), } }); @@ -5403,8 +5298,6 @@ mod tests { "type": "read-only" }, "cwd": cwd, - "history_log_id": 0, - "history_entry_count": 0, }); let event: SessionConfiguredEvent = serde_json::from_value(value)?; diff --git a/codex-rs/protocol/src/session_id.rs b/codex-rs/protocol/src/session_id.rs new file mode 100644 index 0000000000..ac22103d33 --- /dev/null +++ b/codex-rs/protocol/src/session_id.rs @@ -0,0 +1,126 @@ +use std::fmt::Display; + +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +use crate::ThreadId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] +#[ts(type = "string")] +pub struct SessionId { + pub(crate) uuid: Uuid, +} + +impl SessionId { + pub fn new() -> Self { + Self { + uuid: Uuid::now_v7(), + } + } + + pub fn from_string(s: &str) -> Result { + Ok(Self { + uuid: Uuid::parse_str(s)?, + }) + } +} + +impl TryFrom<&str> for SessionId { + type Error = uuid::Error; + + fn try_from(value: &str) -> Result { + Self::from_string(value) + } +} + +impl TryFrom for SessionId { + type Error = uuid::Error; + + fn try_from(value: String) -> Result { + Self::from_string(value.as_str()) + } +} + +impl From for String { + fn from(value: SessionId) -> Self { + value.to_string() + } +} + +impl From for SessionId { + fn from(value: ThreadId) -> Self { + Self { uuid: value.uuid } + } +} + +impl From for ThreadId { + fn from(value: SessionId) -> Self { + ThreadId { uuid: value.uuid } + } +} + +impl Default for SessionId { + fn default() -> Self { + Self::new() + } +} + +impl Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.uuid, f) + } +} + +impl Serialize for SessionId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&self.uuid) + } +} + +impl<'de> Deserialize<'de> for SessionId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?; + Ok(Self { uuid }) + } +} + +impl JsonSchema for SessionId { + fn schema_name() -> String { + "SessionId".to_string() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ::json_schema(generator) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_id_default_is_not_zeroes() { + let id = SessionId::default(); + assert_ne!(id.uuid, Uuid::nil()); + } + + #[test] + fn converts_to_and_from_thread_id() { + let thread_id = ThreadId::new(); + let session_id = SessionId::from(thread_id); + + assert_eq!(ThreadId::from(session_id), thread_id); + } +} diff --git a/codex-rs/protocol/src/thread_id.rs b/codex-rs/protocol/src/thread_id.rs index 8d6d96eff8..d6e9a8825e 100644 --- a/codex-rs/protocol/src/thread_id.rs +++ b/codex-rs/protocol/src/thread_id.rs @@ -11,7 +11,7 @@ use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] #[ts(type = "string")] pub struct ThreadId { - uuid: Uuid, + pub(crate) uuid: Uuid, } impl ThreadId { diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 542073342e..3d52798b8d 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -236,7 +236,6 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option Option<&'static s EventMsg::TurnStarted(_) => Some("turn_started"), EventMsg::TurnComplete(_) => Some("turn_complete"), EventMsg::TurnAborted(_) => Some("turn_aborted"), - EventMsg::ThreadNameUpdated(_) => Some("thread_name_updated"), EventMsg::ThreadRolledBack(_) => Some("thread_rolled_back"), EventMsg::Error(_) => Some("error"), EventMsg::Warning(_) => Some("warning"), @@ -337,9 +332,6 @@ pub(crate) fn wrapped_protocol_event_type(event: &EventMsg) -> Option<&'static s | EventMsg::PatchApplyUpdated(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index c94cd0be7e..45db758c65 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -40,6 +40,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -91,6 +92,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -350,6 +352,7 @@ fn write_rollout_in_sessions_with_cwd( originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index aeb92ade11..558c3fef98 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -98,7 +98,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::AgentReasoningRawContent(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TokenCount(_) - | EventMsg::ThreadNameUpdated(_) | EventMsg::ContextCompacted(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) @@ -155,12 +154,9 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyUpdated(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::WebSearchBegin(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 512c223263..6b549b745c 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -8,7 +8,10 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; +use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_protocol::ThreadId; @@ -61,6 +64,7 @@ use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; use codex_utils_path as path_utils; @@ -88,6 +92,7 @@ pub enum RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, event_persistence_mode: EventPersistenceMode, @@ -164,6 +169,7 @@ impl RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, event_persistence_mode: EventPersistenceMode, @@ -172,6 +178,7 @@ impl RolloutRecorderParams { conversation_id, forked_from_id, source, + thread_source, base_instructions, dynamic_tools, event_persistence_mode, @@ -667,6 +674,7 @@ impl RolloutRecorder { conversation_id, forked_from_id, source, + thread_source, base_instructions, dynamic_tools, event_persistence_mode, @@ -695,6 +703,7 @@ impl RolloutRecorder { agent_role: source.get_agent_role(), agent_path: source.get_agent_path().map(Into::into), source, + thread_source, model_provider: Some(config.model_provider_id().to_string()), base_instructions: Some(base_instructions), dynamic_tools: if dynamic_tools.is_empty() { @@ -1424,9 +1433,28 @@ struct RolloutWriterState { state_builder: Option, default_provider: String, generate_memories: bool, + thread_updated_at_touch: ThreadUpdatedAtTouch, last_logged_error: Option, } +#[cfg(not(test))] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5); +#[cfg(test)] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Default)] +struct ThreadUpdatedAtTouch { + last_persisted_at: Option, + pending_touch: Option<(ThreadId, DateTime)>, +} + +impl ThreadUpdatedAtTouch { + fn mark_persisted(&mut self, now: Instant) { + self.last_persisted_at = Some(now); + self.pending_touch = None; + } +} + impl RolloutWriterState { #[allow(clippy::too_many_arguments)] fn new( @@ -1454,6 +1482,7 @@ impl RolloutWriterState { state_builder, default_provider, generate_memories, + thread_updated_at_touch: ThreadUpdatedAtTouch::default(), last_logged_error: None, } } @@ -1486,7 +1515,19 @@ impl RolloutWriterState { if self.is_deferred() && self.pending_items.is_empty() { return Ok(()); } - self.write_pending_with_recovery("shutdown").await + self.write_pending_with_recovery("shutdown").await?; + if let Some((thread_id, updated_at)) = self.thread_updated_at_touch.pending_touch.take() + && state_db::touch_thread_updated_at( + self.state_db_ctx.as_deref(), + Some(thread_id), + updated_at, + "rollout_writer_shutdown", + ) + .await + { + self.thread_updated_at_touch.mark_persisted(Instant::now()); + } + Ok(()) } async fn write_pending_with_recovery(&mut self, operation: &str) -> std::io::Result<()> { @@ -1563,6 +1604,7 @@ impl RolloutWriterState { &mut self.state_builder, self.default_provider.as_str(), self.generate_memories, + &mut self.thread_updated_at_touch, ) .await?; self.meta = None; @@ -1606,6 +1648,7 @@ impl RolloutWriterState { written_items.as_slice(), self.default_provider.as_str(), /*new_thread_memory_mode*/ None, + &mut self.thread_updated_at_touch, ) .await; } @@ -1677,6 +1720,7 @@ async fn write_session_meta( state_builder: &mut Option, default_provider: &str, generate_memories: bool, + thread_updated_at_touch: &mut ThreadUpdatedAtTouch, ) -> std::io::Result<()> { let git_info = collect_git_info(cwd).await.map(|info| ProtocolGitInfo { commit_hash: info.commit_hash, @@ -1702,6 +1746,7 @@ async fn write_session_meta( std::slice::from_ref(&rollout_item), default_provider, (!generate_memories).then_some("disabled"), + thread_updated_at_touch, ) .await; Ok(()) @@ -1714,8 +1759,10 @@ async fn sync_thread_state_after_write( items: &[RolloutItem], default_provider: &str, new_thread_memory_mode: Option<&str>, + thread_updated_at_touch: &mut ThreadUpdatedAtTouch, ) { let updated_at = Utc::now(); + let now = Instant::now(); if new_thread_memory_mode.is_some() || items .iter() @@ -1732,15 +1779,27 @@ async fn sync_thread_state_after_write( Some(updated_at), ) .await; + thread_updated_at_touch.mark_persisted(now); return; } let thread_id = state_builder .map(|builder| builder.id) .or_else(|| metadata::builder_from_items(items, rollout_path).map(|builder| builder.id)); + if thread_updated_at_touch + .last_persisted_at + .is_some_and(|last_persisted_at| { + now.duration_since(last_persisted_at) < THREAD_UPDATED_AT_TOUCH_INTERVAL + }) + { + thread_updated_at_touch.pending_touch = thread_id.map(|thread_id| (thread_id, updated_at)); + return; + } + if state_db::touch_thread_updated_at(state_db_ctx, thread_id, updated_at, "rollout_writer") .await { + thread_updated_at_touch.mark_persisted(now); return; } state_db::apply_rollout_items( @@ -1754,6 +1813,7 @@ async fn sync_thread_state_after_write( Some(updated_at), ) .await; + thread_updated_at_touch.mark_persisted(now); } /// Append one already-filtered rollout item to an existing rollout JSONL file. diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 5711c47bad..505cd59929 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -91,6 +91,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -306,6 +307,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< thread_id, /*forked_from_id*/ None, SessionSource::Exec, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, @@ -386,6 +388,7 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i thread_id, /*forked_from_id*/ None, SessionSource::Exec, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, @@ -466,7 +469,7 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st } #[tokio::test] -async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { +async fn metadata_irrelevant_events_coalesce_state_db_updated_at() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); @@ -485,6 +488,7 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu thread_id, /*forked_from_id*/ None, SessionSource::Cli, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, @@ -515,8 +519,6 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu let initial_title = initial_thread.title.clone(); let initial_first_user_message = initial_thread.first_user_message.clone(); - tokio::time::sleep(Duration::from_secs(1)).await; - recorder .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { @@ -534,17 +536,123 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu .expect("thread should load after agent message") .expect("thread should still exist"); - assert!(updated_thread.updated_at > initial_updated_at); + assert_eq!(updated_thread.updated_at, initial_updated_at); assert_eq!(updated_thread.title, initial_title); assert_eq!( updated_thread.first_user_message, initial_first_user_message ); + tokio::time::sleep(THREAD_UPDATED_AT_TOUCH_INTERVAL + Duration::from_millis(10)).await; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "more assistant text".to_string(), + phase: None, + memory_citation: None, + }, + ))]) + .await?; + recorder.flush().await?; + + let refreshed_thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load after refresh") + .expect("thread should still exist"); + assert!(refreshed_thread.updated_at > initial_updated_at); + assert_eq!(refreshed_thread.title, initial_title); + assert_eq!( + refreshed_thread.first_user_message, + initial_first_user_message + ); + recorder.shutdown().await?; Ok(()) } +#[tokio::test] +async fn shutdown_flushes_pending_metadata_irrelevant_updated_at() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + + let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) + .await + .expect("state db should initialize"); + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); + + let thread_id = ThreadId::new(); + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + /*forked_from_id*/ None, + SessionSource::Cli, + /*thread_source*/ None, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + Some(state_db.clone()), + /*state_builder*/ None, + ) + .await?; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "first-user-message".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]) + .await?; + recorder.persist().await?; + recorder.flush().await?; + let initial_updated_at = state_db + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist") + .updated_at; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "assistant text".to_string(), + phase: None, + memory_citation: None, + }, + ))]) + .await?; + recorder.flush().await?; + assert_eq!( + state_db + .get_thread(thread_id) + .await + .expect("thread should load before shutdown") + .expect("thread should still exist") + .updated_at, + initial_updated_at + ); + + recorder.shutdown().await?; + + let shutdown_updated_at = state_db + .get_thread(thread_id) + .await + .expect("thread should load after shutdown") + .expect("thread should still exist") + .updated_at; + assert!(shutdown_updated_at > initial_updated_at); + Ok(()) +} + #[tokio::test] async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()> { @@ -570,6 +678,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> }, ))]; + let mut thread_updated_at_touch = ThreadUpdatedAtTouch::default(); sync_thread_state_after_write( Some(state_db.as_ref()), rollout_path.as_path(), @@ -577,6 +686,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> items.as_slice(), config.model_provider_id.as_str(), /*new_thread_memory_mode*/ None, + &mut thread_updated_at_touch, ) .await; diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index fbef7eb4f9..757b08b4d4 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -32,6 +32,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index b5c2790dae..fdfed3dadc 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1214,6 +1214,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/sandboxing/src/bwrap.rs b/codex-rs/sandboxing/src/bwrap.rs index 3435c6d193..e0eee177fb 100644 --- a/codex-rs/sandboxing/src/bwrap.rs +++ b/codex-rs/sandboxing/src/bwrap.rs @@ -1,9 +1,16 @@ use crate::policy_transforms::should_require_platform_sandbox; use codex_protocol::models::PermissionProfile; +use std::io::ErrorKind; +use std::io::Read; +use std::os::fd::AsRawFd; use std::path::Path; use std::path::PathBuf; use std::process::Command; use std::process::Output; +use std::process::Stdio; +use std::thread; +use std::time::Duration; +use std::time::Instant; const SYSTEM_BWRAP_PROGRAM: &str = "bwrap"; const MISSING_BWRAP_WARNING: &str = concat!( @@ -11,7 +18,7 @@ const MISSING_BWRAP_WARNING: &str = concat!( "Install bubblewrap with your OS package manager. ", "See the sandbox prerequisites: ", "https://developers.openai.com/codex/concepts/sandboxing#prerequisites. ", - "Codex will use the vendored bubblewrap in the meantime.", + "Codex will use the bundled bubblewrap in the meantime.", ); const USER_NAMESPACE_WARNING: &str = "Codex's Linux sandbox uses bubblewrap and needs access to create user namespaces."; @@ -26,6 +33,9 @@ const USER_NAMESPACE_FAILURES: [&str; 4] = [ "setting up uid map: Permission denied", "No permissions to create a new namespace", ]; +const SYSTEM_BWRAP_PROBE_TIMEOUT: Duration = Duration::from_millis(500); +const SYSTEM_BWRAP_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(50); +const SYSTEM_BWRAP_PROBE_STDERR_LIMIT_BYTES: u64 = 64 * 1024; pub fn system_bwrap_warning(permission_profile: &PermissionProfile) -> Option { if !should_warn_about_system_bwrap(permission_profile) { @@ -54,15 +64,15 @@ fn system_bwrap_warning_for_path(system_bwrap_path: Option<&Path>) -> Option bool { - let output = match Command::new(system_bwrap_path) +fn system_bwrap_has_user_namespace_access(system_bwrap_path: &Path, timeout: Duration) -> bool { + let mut child = match Command::new(system_bwrap_path) .args([ "--unshare-user", "--unshare-net", @@ -71,13 +81,58 @@ fn system_bwrap_has_user_namespace_access(system_bwrap_path: &Path) -> bool { "/", "/bin/true", ]) - .output() + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() { - Ok(output) => output, + Ok(child) => child, Err(_) => return true, }; - output.status.success() || !is_user_namespace_failure(&output) + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stderr = child.stderr.take().map_or_else(Vec::new, |stderr| { + let fd = stderr.as_raw_fd(); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 + || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 + { + return Vec::new(); + } + + let mut bytes = Vec::new(); + let mut stderr = stderr.take(SYSTEM_BWRAP_PROBE_STDERR_LIMIT_BYTES); + if let Err(err) = stderr.read_to_end(&mut bytes) + && err.kind() != ErrorKind::WouldBlock + { + return bytes; + } + bytes + }); + let output = Output { + status, + stdout: Vec::new(), + stderr, + }; + return output.status.success() || !is_user_namespace_failure(&output); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return true; + } + thread::sleep(SYSTEM_BWRAP_PROBE_POLL_INTERVAL); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return true; + } + } + } } pub(crate) fn is_wsl1() -> bool { diff --git a/codex-rs/sandboxing/src/bwrap_tests.rs b/codex-rs/sandboxing/src/bwrap_tests.rs index f36848e1e7..3c7a50392d 100644 --- a/codex-rs/sandboxing/src/bwrap_tests.rs +++ b/codex-rs/sandboxing/src/bwrap_tests.rs @@ -2,6 +2,8 @@ use super::*; use pretty_assertions::assert_eq; use std::path::Path; use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use tempfile::tempdir; #[test] @@ -44,6 +46,43 @@ exit 1 assert_eq!(system_bwrap_warning_for_path(Some(fake_bwrap_path)), None); } +#[test] +fn system_bwrap_probe_times_out_without_reporting_a_warning() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +sleep 1 +exit 0 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let started_at = Instant::now(); + + assert!(system_bwrap_has_user_namespace_access( + fake_bwrap_path, + Duration::from_millis(10), + )); + assert!(started_at.elapsed() < Duration::from_millis(500)); +} + +#[test] +fn system_bwrap_probe_does_not_wait_for_descendants_holding_stderr_open() { + let fake_bwrap = write_fake_bwrap( + r#"#!/bin/sh +echo 'No permissions to create a new namespace' >&2 +sleep 1 & +exit 1 +"#, + ); + let fake_bwrap_path: &Path = fake_bwrap.as_ref(); + let started_at = Instant::now(); + + assert!(!system_bwrap_has_user_namespace_access( + fake_bwrap_path, + Duration::from_millis(100), + )); + assert!(started_at.elapsed() < Duration::from_millis(500)); +} + #[test] fn detects_wsl1_proc_version_formats() { assert!(proc_version_indicates_wsl1( diff --git a/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs b/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs index 19babd82e8..fef98e8362 100644 --- a/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs @@ -68,32 +68,6 @@ fn is_git_global_option_with_inline_value(arg: &str) -> bool { ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2) } -/// Git global options that can redirect config, repository, or helper lookup -/// and therefore must never be auto-approved as "safe". -pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool { - matches!( - arg, - // `-C` can redirect Git into a repo whose config runs helpers such as - // `core.fsmonitor` during read-only commands like `status`. - "-C" | "-c" - | "--config-env" - | "--exec-path" - | "--git-dir" - | "--namespace" - | "--super-prefix" - | "--work-tree" - ) || matches!( - arg, - s if ((s.starts_with("-C") || s.starts_with("-c")) && s.len() > 2) - || s.starts_with("--config-env=") - || s.starts_with("--exec-path=") - || s.starts_with("--git-dir=") - || s.starts_with("--namespace=") - || s.starts_with("--super-prefix=") - || s.starts_with("--work-tree=") - ) -} - pub(crate) fn executable_name_lookup_key(raw: &str) -> Option { #[cfg(windows)] { @@ -200,12 +174,6 @@ mod tests { assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"]))); } - #[test] - fn git_dash_c_requires_prompt() { - assert!(git_global_option_requires_prompt("-C")); - assert!(git_global_option_requires_prompt("-C/path/to/repo")); - } - #[test] fn direct_powershell_words_reuse_windows_dangerous_detection() { let command = vec_str(&["Remove-Item", "test", "-Force"]); diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs index 5e2ffec9f5..b35144a12b 100644 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_safe_command.rs @@ -4,7 +4,6 @@ use crate::command_safety::is_dangerous_command::executable_name_lookup_key; // may appear before it (e.g., `-C`, `-c`, `--git-dir`). // Implemented in `is_dangerous_command` and shared here. use crate::command_safety::is_dangerous_command::find_git_subcommand; -use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt; use crate::command_safety::windows_safe_commands::is_safe_command_windows; #[cfg(windows)] use crate::command_safety::windows_safe_commands::is_safe_powershell_words as is_safe_powershell_words_windows; @@ -151,36 +150,7 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { } // Git - Some("git") => { - // Global options that redirect config, repository, or helper - // lookup can make otherwise read-only git commands execute - // attacker-controlled code, so they must never be auto-approved. - if git_has_unsafe_global_option(command) { - return false; - } - - let Some((subcommand_idx, subcommand)) = - find_git_subcommand(command, &["status", "log", "diff", "show", "branch"]) - else { - return false; - }; - - let subcommand_args = &command[subcommand_idx + 1..]; - - match subcommand { - "status" | "log" | "diff" | "show" => { - git_subcommand_args_are_read_only(subcommand_args) - } - "branch" => { - git_subcommand_args_are_read_only(subcommand_args) - && git_branch_is_read_only(subcommand_args) - } - other => { - debug_assert!(false, "unexpected git subcommand from matcher: {other}"); - false - } - } - } + Some("git") => is_safe_git_command(command), // Special-case `sed -n {N|M,N}p` Some("sed") @@ -198,6 +168,33 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { } } +pub(crate) fn is_safe_git_command(command: &[String]) -> bool { + let Some((subcommand_idx, subcommand)) = + find_git_subcommand(command, &["status", "log", "diff", "show", "branch"]) + else { + return false; + }; + + let global_args = &command[1..subcommand_idx]; + if git_has_unsafe_global_option(global_args) { + return false; + } + + let subcommand_args = &command[subcommand_idx + 1..]; + + match subcommand { + "status" | "log" | "diff" | "show" => git_subcommand_args_are_read_only(subcommand_args), + "branch" => { + git_subcommand_args_are_read_only(subcommand_args) + && git_branch_is_read_only(subcommand_args) + } + other => { + debug_assert!(false, "unexpected git subcommand from matcher: {other}"); + false + } + } +} + // Treat `git branch` as safe only when the arguments clearly indicate // a read-only query, not a branch mutation (create/rename/delete). fn git_branch_is_read_only(branch_args: &[String]) -> bool { @@ -226,30 +223,71 @@ fn git_branch_is_read_only(branch_args: &[String]) -> bool { saw_read_only_flag } -fn git_has_unsafe_global_option(command: &[String]) -> bool { - command +#[derive(Clone, Copy)] +enum GitOptionPattern { + Exact(&'static str), + ShortWithInlineValue(&'static str), + Prefix(&'static str), +} + +const UNSAFE_GIT_GLOBAL_OPTIONS: &[GitOptionPattern] = &[ + GitOptionPattern::Exact("-C"), + GitOptionPattern::ShortWithInlineValue("-C"), + GitOptionPattern::Exact("-c"), + GitOptionPattern::ShortWithInlineValue("-c"), + GitOptionPattern::Exact("-p"), + GitOptionPattern::Exact("--config-env"), + GitOptionPattern::Prefix("--config-env="), + GitOptionPattern::Exact("--exec-path"), + GitOptionPattern::Prefix("--exec-path="), + GitOptionPattern::Exact("--git-dir"), + GitOptionPattern::Prefix("--git-dir="), + GitOptionPattern::Exact("--namespace"), + GitOptionPattern::Prefix("--namespace="), + GitOptionPattern::Exact("--paginate"), + GitOptionPattern::Exact("--super-prefix"), + GitOptionPattern::Prefix("--super-prefix="), + GitOptionPattern::Exact("--work-tree"), + GitOptionPattern::Prefix("--work-tree="), +]; + +const UNSAFE_GIT_SUBCOMMAND_OPTIONS: &[GitOptionPattern] = &[ + GitOptionPattern::Exact("--output"), + GitOptionPattern::Prefix("--output="), + GitOptionPattern::Exact("--ext-diff"), + GitOptionPattern::Exact("--textconv"), + GitOptionPattern::Exact("--exec"), + GitOptionPattern::Prefix("--exec="), +]; + +impl GitOptionPattern { + fn matches(self, arg: &str) -> bool { + match self { + GitOptionPattern::Exact(option) => arg == option, + GitOptionPattern::ShortWithInlineValue(option) => { + arg.starts_with(option) && arg.len() > option.len() + } + GitOptionPattern::Prefix(prefix) => arg.starts_with(prefix), + } + } +} + +fn git_matches_option_pattern(arg: &str, patterns: &[GitOptionPattern]) -> bool { + patterns.iter().any(|pattern| pattern.matches(arg)) +} + +fn git_has_unsafe_global_option(global_args: &[String]) -> bool { + global_args .iter() - .skip(1) .map(String::as_str) - .any(git_global_option_requires_prompt) + .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_GLOBAL_OPTIONS)) } fn git_subcommand_args_are_read_only(args: &[String]) -> bool { - // Flags that can write to disk or execute external tools should never be - // auto-approved on an unsandboxed machine. - const UNSAFE_GIT_FLAGS: &[&str] = &[ - "--output", - "--ext-diff", - "--textconv", - "--exec", - "--paginate", - ]; - - !args.iter().map(String::as_str).any(|arg| { - UNSAFE_GIT_FLAGS.contains(&arg) - || arg.starts_with("--output=") - || arg.starts_with("--exec=") - }) + !args + .iter() + .map(String::as_str) + .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_SUBCOMMAND_OPTIONS)) } // (bash parsing helpers implemented in crate::bash) @@ -395,6 +433,43 @@ mod tests { ]))); } + #[test] + fn git_global_pagination_flags_are_not_safe() { + assert!(!is_known_safe_command(&vec_str(&[ + "git", + "--paginate", + "log", + "-1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "git", "-p", "log", "-1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git --paginate log -1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git -p log -1", + ]))); + } + + #[test] + fn git_subcommand_patch_flags_remain_safe() { + assert!(is_known_safe_command(&vec_str(&["git", "log", "-p", "-1"]))); + assert!(is_known_safe_command(&vec_str(&["git", "diff", "-p"]))); + assert!(is_known_safe_command(&vec_str(&[ + "git", "show", "-p", "HEAD", + ]))); + assert!(is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git log -p -1", + ]))); + } + #[test] fn git_global_override_flags_are_not_safe() { assert!(!is_known_safe_command(&vec_str(&[ @@ -542,8 +617,15 @@ mod tests { return; } + let Some(powershell) = crate::powershell::try_find_pwsh_executable_blocking() + .or_else(crate::powershell::try_find_powershell_executable_blocking) + else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + assert!(is_known_safe_command(&vec_str(&[ - r"C:\Program Files\PowerShell\7\pwsh.exe", + powershell, "-Command", "Get-Location", ]))); diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs index 1dd628f427..8ef3f8e8f9 100644 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs @@ -1,4 +1,4 @@ -use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt; +use crate::command_safety::is_safe_command::is_safe_git_command; use crate::command_safety::powershell_parser::PowershellParseOutcome; use crate::command_safety::powershell_parser::parse_with_powershell_ast; use std::path::Path; @@ -221,37 +221,11 @@ fn is_safe_ripgrep(words: &[String]) -> bool { }) } -/// Ensures a Git command sticks to whitelisted read-only subcommands and flags. -fn is_safe_git_command(words: &[String]) -> bool { - const SAFE_SUBCOMMANDS: &[&str] = &["status", "log", "show", "diff", "cat-file"]; - - for arg in words.iter().skip(1) { - let arg_lc = arg.to_ascii_lowercase(); - - if arg.starts_with('-') { - if git_global_option_requires_prompt(&arg_lc) - || arg.eq_ignore_ascii_case("--config") - || arg_lc.starts_with("--config=") - { - // Examples rejected here: "pwsh -Command 'git --git-dir=.evil-git diff'" and - // "pwsh -Command 'git -c core.pager=cat show HEAD:foo.rs'". - return false; - } - - continue; - } - - return SAFE_SUBCOMMANDS.contains(&arg_lc.as_str()); - } - - // Examples rejected here: "pwsh -Command 'git'" and "pwsh -Command 'git status --short | Remove-Item foo'". - false -} - #[cfg(all(test, windows))] mod tests { use super::*; use crate::powershell::try_find_pwsh_executable_blocking; + use pretty_assertions::assert_eq; use std::string::ToString; /// Converts a slice of string literals into owned `String`s for the tests. @@ -342,7 +316,7 @@ mod tests { assert!(is_safe_command_windows(&[ pwsh.clone(), "-Command".to_string(), - "-git cat-file -p HEAD:foo.rs".to_string() + "git show HEAD:foo.rs".to_string() ])); assert!(is_safe_command_windows(&[ @@ -393,6 +367,41 @@ mod tests { } } + #[test] + fn rejects_git_subcommand_options_with_side_effects() { + let results: Vec<(&str, bool)> = [ + "git diff --output codex_poc.txt", + "git diff --ext-diff HEAD", + "git log --textconv -1", + "git show --output=codex_poc.txt HEAD", + "git cat-file --filters HEAD:a.txt", + ] + .into_iter() + .map(|script| { + ( + script, + is_safe_command_windows(&[ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + script.to_string(), + ]), + ) + }) + .collect(); + + assert_eq!( + vec![ + ("git diff --output codex_poc.txt", false), + ("git diff --ext-diff HEAD", false), + ("git log --textconv -1", false), + ("git show --output=codex_poc.txt HEAD", false), + ("git cat-file --filters HEAD:a.txt", false), + ], + results + ); + } + #[test] fn rejects_powershell_commands_with_side_effects() { assert!(!is_safe_command_windows(&vec_str(&[ diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index eb12887b72..4994fd8802 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -8,6 +8,12 @@ description: "Use when the user asks how to build with OpenAI products or APIs a Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. Always prioritize the developer docs MCP tools over web.run for OpenAI-related questions. This skill also owns model selection, API model migration, and prompt-upgrade guidance. Only if the MCP server is installed and returns no meaningful results should you fall back to web search. +## API Key Setup + +For requests to build, run, configure, debug, or implement an API-backed app, script, CLI, generator, or tool, use `openai-platform-api-key` first when available. After that credential gate is resolved, return here for current docs as needed. + +Use this skill directly for docs-only questions, citations, model/API guidance, conceptual explanations, and examples that do not require building or running an API-backed artifact. + ## Quick start - Use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. diff --git a/codex-rs/state/migrations/0030_threads_thread_source.sql b/codex-rs/state/migrations/0030_threads_thread_source.sql new file mode 100644 index 0000000000..4f11c9a3f8 --- /dev/null +++ b/codex-rs/state/migrations/0030_threads_thread_source.sql @@ -0,0 +1 @@ +ALTER TABLE threads ADD COLUMN thread_source TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index a4a0ab0f6a..723c5084eb 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -33,9 +33,7 @@ pub fn apply_rollout_item( pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool { match item { RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) => true, - RolloutItem::EventMsg( - EventMsg::TokenCount(_) | EventMsg::UserMessage(_) | EventMsg::ThreadNameUpdated(_), - ) => true, + RolloutItem::EventMsg(EventMsg::TokenCount(_) | EventMsg::UserMessage(_)) => true, RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) => { false } @@ -50,6 +48,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } metadata.id = meta_line.meta.id; metadata.source = enum_to_string(&meta_line.meta.source); + metadata.thread_source = meta_line.meta.thread_source; metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); metadata.agent_role = meta_line.meta.agent_role.clone(); metadata.agent_path = meta_line.meta.agent_path.clone(); @@ -97,13 +96,6 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) { } } } - EventMsg::ThreadNameUpdated(updated) => { - if let Some(title) = updated.thread_name.as_deref() - && !title.trim().is_empty() - { - metadata.title = title.trim().to_string(); - } - } _ => {} } } @@ -159,7 +151,6 @@ mod tests { use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::UserMessageEvent; @@ -205,25 +196,6 @@ mod tests { assert_eq!(metadata.title, "actual user request"); } - #[test] - fn thread_name_update_replaces_title_without_changing_first_user_message() { - let mut metadata = metadata_for_test(); - metadata.title = "actual user request".to_string(); - metadata.first_user_message = Some("actual user request".to_string()); - let item = RolloutItem::EventMsg(EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { - thread_id: metadata.id, - thread_name: Some("saved-session".to_string()), - })); - - apply_rollout_item(&mut metadata, &item, "test-provider"); - - assert_eq!( - metadata.first_user_message.as_deref(), - Some("actual user request") - ); - assert_eq!(metadata.title, "saved-session"); - } - #[test] fn event_msg_image_only_user_message_sets_image_placeholder_preview() { let mut metadata = metadata_for_test(); @@ -278,6 +250,7 @@ mod tests { originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -411,6 +384,7 @@ mod tests { originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -437,6 +411,7 @@ mod tests { created_at, updated_at: created_at, source: "cli".to_string(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index bddb2fb364..e5e2d1d1f8 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use sqlx::Row; use sqlx::sqlite::SqliteRow; use std::path::PathBuf; @@ -68,6 +69,8 @@ pub struct ThreadMetadata { pub updated_at: DateTime, /// The session source (stringified enum). pub source: String, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. pub agent_nickname: Option, /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. @@ -117,6 +120,8 @@ pub struct ThreadMetadataBuilder { pub updated_at: Option>, /// The session source. pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Optional random unique nickname assigned to the session. pub agent_nickname: Option, /// Optional role (agent_role) assigned to the session. @@ -157,6 +162,7 @@ impl ThreadMetadataBuilder { created_at, updated_at: None, source, + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, @@ -188,6 +194,7 @@ impl ThreadMetadataBuilder { created_at, updated_at, source, + thread_source: self.thread_source, agent_nickname: self.agent_nickname.clone(), agent_role: self.agent_role.clone(), agent_path: self @@ -313,6 +320,7 @@ pub(crate) struct ThreadRow { created_at: i64, updated_at: i64, source: String, + thread_source: Option, agent_nickname: Option, agent_role: Option, agent_path: Option, @@ -340,6 +348,7 @@ impl ThreadRow { created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, source: row.try_get("source")?, + thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, agent_role: row.try_get("agent_role")?, agent_path: row.try_get("agent_path")?, @@ -371,6 +380,7 @@ impl TryFrom for ThreadMetadata { created_at, updated_at, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -389,12 +399,17 @@ impl TryFrom for ThreadMetadata { git_branch, git_origin_url, } = row; + let thread_source = thread_source + .map(|thread_source| thread_source.parse()) + .transpose() + .map_err(anyhow::Error::msg)?; Ok(Self { id: ThreadId::try_from(id)?, rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -480,6 +495,7 @@ mod tests { created_at: 1_700_000_000, updated_at: 1_700_000_100, source: "cli".to_string(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, @@ -508,6 +524,7 @@ mod tests { created_at: DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 18f81348a2..2dc24788d8 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -172,28 +172,15 @@ fn base_sqlite_options(path: &Path) -> SqliteConnectOptions { } async fn open_state_sqlite(path: &Path, migrator: &Migrator) -> anyhow::Result { + // New state DBs should use incremental auto-vacuum, but retrofitting an + // existing DB requires a full VACUUM. Do not attempt that during process + // startup: it is maintenance work that can contend with foreground writers. let options = base_sqlite_options(path).auto_vacuum(SqliteAutoVacuum::Incremental); let pool = SqlitePoolOptions::new() .max_connections(5) .connect_with(options) .await?; migrator.run(&pool).await?; - let auto_vacuum = sqlx::query_scalar::<_, i64>("PRAGMA auto_vacuum") - .fetch_one(&pool) - .await?; - if auto_vacuum != SqliteAutoVacuum::Incremental as i64 { - // Existing state DBs need one non-transactional `VACUUM` before - // SQLite persists `auto_vacuum = INCREMENTAL` in the database header. - sqlx::query("PRAGMA auto_vacuum = INCREMENTAL") - .execute(&pool) - .await?; - // We do it on best effort. If the lock can't be acquired, it will be done at next run. - let _ = sqlx::query("VACUUM").execute(&pool).await; - } - // We do it on best effort. If the lock can't be acquired, it will be done at next run. - let _ = sqlx::query("PRAGMA incremental_vacuum") - .execute(&pool) - .await; Ok(pool) } diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 2223310d9f..6c878db624 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -300,10 +300,10 @@ WHERE id IN ( return Ok(()); }; self.delete_logs_before(cutoff.timestamp()).await?; - sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") - .execute(self.logs_pool.as_ref()) - .await?; - sqlx::query("PRAGMA incremental_vacuum") + // Startup cleanup should not wait behind or block foreground work. + // PASSIVE checkpoints copy whatever is immediately available and skip + // frames that would require waiting on active readers or writers. + sqlx::query("PRAGMA wal_checkpoint(PASSIVE)") .execute(self.logs_pool.as_ref()) .await?; Ok(()) diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 5b75225b1e..186f2dd341 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -137,6 +137,7 @@ SELECT threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, threads.source, + threads.thread_source, threads.agent_path, threads.agent_nickname, threads.agent_role, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 5f07336853..aa1785ba7d 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -48,6 +48,7 @@ pub(super) fn test_thread_metadata( created_at: now, updated_at: now, source: "cli".to_string(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 906a3bb39a..1795a864d8 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -13,6 +13,7 @@ SELECT threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, threads.source, + threads.thread_source, threads.agent_nickname, threads.agent_role, threads.agent_path, @@ -486,6 +487,7 @@ INSERT INTO threads ( created_at_ms, updated_at_ms, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -505,7 +507,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -516,6 +518,11 @@ ON CONFLICT(id) DO NOTHING .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) .bind(metadata.source.as_str()) + .bind( + metadata + .thread_source + .map(codex_protocol::protocol::ThreadSource::as_str), + ) .bind(metadata.agent_nickname.as_deref()) .bind(metadata.agent_role.as_deref()) .bind(metadata.agent_path.as_deref()) @@ -670,6 +677,9 @@ WHERE id = ? creation_memory_mode: Option<&str>, ) -> anyhow::Result<()> { let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?; + // Backfill/reconcile callers merge existing git info before upserting, but that + // read/modify/write is not atomic. Preserve non-null SQLite git fields here so + // an explicit metadata update cannot be lost if a stale rollout upsert lands later. sqlx::query( r#" INSERT INTO threads ( @@ -680,6 +690,7 @@ INSERT INTO threads ( created_at_ms, updated_at_ms, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -699,7 +710,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -707,6 +718,7 @@ ON CONFLICT(id) DO UPDATE SET created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms, source = excluded.source, + thread_source = excluded.thread_source, agent_nickname = excluded.agent_nickname, agent_role = excluded.agent_role, agent_path = excluded.agent_path, @@ -722,9 +734,9 @@ ON CONFLICT(id) DO UPDATE SET first_user_message = excluded.first_user_message, archived = excluded.archived, archived_at = excluded.archived_at, - git_sha = excluded.git_sha, - git_branch = excluded.git_branch, - git_origin_url = excluded.git_origin_url + git_sha = COALESCE(threads.git_sha, excluded.git_sha), + git_branch = COALESCE(threads.git_branch, excluded.git_branch), + git_origin_url = COALESCE(threads.git_origin_url, excluded.git_origin_url) "#, ) .bind(metadata.id.to_string()) @@ -734,6 +746,11 @@ ON CONFLICT(id) DO UPDATE SET .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) .bind(metadata.source.as_str()) + .bind( + metadata + .thread_source + .map(codex_protocol::protocol::ThreadSource::as_str), + ) .bind(metadata.agent_nickname.as_deref()) .bind(metadata.agent_role.as_deref()) .bind(metadata.agent_path.as_deref()) @@ -955,6 +972,7 @@ SELECT threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, threads.source, + threads.thread_source, threads.agent_nickname, threads.agent_role, threads.agent_path, @@ -1358,6 +1376,7 @@ mod tests { originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -1416,6 +1435,7 @@ mod tests { originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -1452,6 +1472,47 @@ mod tests { ); } + #[tokio::test] + async fn upsert_thread_preserves_existing_git_fields_atomically() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000458").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.git_sha = Some("sqlite-sha".to_string()); + metadata.git_branch = Some("sqlite-branch".to_string()); + metadata.git_origin_url = Some("git@example.com:openai/codex.git".to_string()); + + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let mut rollout_metadata = metadata.clone(); + rollout_metadata.git_sha = Some("rollout-sha".to_string()); + rollout_metadata.git_branch = Some("rollout-branch".to_string()); + rollout_metadata.git_origin_url = Some("https://example.com/repo.git".to_string()); + + runtime + .upsert_thread(&rollout_metadata) + .await + .expect("rollout upsert should succeed"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.git_sha.as_deref(), Some("sqlite-sha")); + assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch")); + assert_eq!( + persisted.git_origin_url.as_deref(), + Some("git@example.com:openai/codex.git") + ); + } + #[tokio::test] async fn update_thread_git_info_preserves_newer_non_git_metadata() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 7064d9e4ce..4ad937f9af 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -40,6 +40,7 @@ use codex_core_api::Permissions; use codex_core_api::ProjectConfig; use codex_core_api::RealtimeAudioConfig; use codex_core_api::RealtimeConfig; +use codex_core_api::SessionPickerViewMode; use codex_core_api::SessionSource; use codex_core_api::ShellEnvironmentPolicy; use codex_core_api::TerminalResizeReflowConfig; @@ -51,11 +52,13 @@ use codex_core_api::TuiNotificationSettings; use codex_core_api::UriBasedFileOpener; use codex_core_api::UserInput; use codex_core_api::WebSearchMode; +use codex_core_api::agent_graph_store_from_state_db; use codex_core_api::arg0_dispatch_or_else; use codex_core_api::built_in_model_providers; use codex_core_api::find_codex_home; -use codex_core_api::init_state_db; +use codex_core_api::init_state_db_from_config; use codex_core_api::item_event_to_server_notification; +use codex_core_api::resolve_installation_id; use codex_core_api::set_default_originator; use codex_core_api::thread_store_from_config; @@ -103,7 +106,6 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { }; let config = new_config(args.model, arg0_paths)?; - let state_db = init_state_db(&config).await; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; @@ -111,17 +113,24 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { config.codex_self_exe.clone(), config.codex_linux_sandbox_exe.clone(), )?; + let Some(state_db) = init_state_db_from_config(&config).await else { + bail!("thread manager sample requires state db"); + }; let thread_store = thread_store_from_config(&config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await); + let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( &config, auth_manager, SessionSource::Exec, environment_manager, /*analytics_events_client*/ None, - Arc::clone(&thread_store), state_db, + Arc::clone(&thread_store), + agent_graph_store, + installation_id, ); let NewThread { @@ -197,8 +206,10 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_raw_output_mode: false, terminal_resize_reflow: TerminalResizeReflowConfig::default(), tui_keymap: TuiKeymap::default(), + tui_session_picker_view: SessionPickerViewMode::Dense, tui_vim_mode_default: false, cwd, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index c54ecb4af2..fca3d21e62 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -272,6 +272,7 @@ fn stored_thread_from_state( cwd: PathBuf::new(), cli_version: "test".to_string(), source: created.source.clone(), + thread_source: created.thread_source, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index bcce1c7645..ffbe2a5645 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -10,7 +10,9 @@ use crate::AppendThreadItemsParams; use crate::CreateThreadParams; use crate::LoadThreadHistoryParams; use crate::LocalThreadStore; +use crate::ReadThreadParams; use crate::ResumeThreadParams; +use crate::StoredThread; use crate::StoredThreadHistory; use crate::ThreadMetadataPatch; use crate::ThreadStore; @@ -139,6 +141,20 @@ impl LiveThread { .await } + pub async fn read_thread( + &self, + include_archived: bool, + include_history: bool, + ) -> ThreadStoreResult { + self.thread_store + .read_thread(ReadThreadParams { + thread_id: self.thread_id, + include_archived, + include_history, + }) + .await + } + pub async fn update_memory_mode( &self, mode: ThreadMemoryMode, @@ -157,6 +173,20 @@ impl LiveThread { Ok(()) } + pub async fn update_metadata( + &self, + patch: ThreadMetadataPatch, + include_archived: bool, + ) -> ThreadStoreResult { + self.thread_store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id: self.thread_id, + patch, + include_archived, + }) + .await + } + /// Returns the live local rollout path for legacy local-only callers. /// /// Remote stores do not expose rollout files, so they return `Ok(None)`. diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index 8fb214e98c..268a6ffc47 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -13,11 +13,11 @@ pub(super) async fn archive_thread( params: ArchiveThreadParams, ) -> ThreadStoreResult<()> { let thread_id = params.thread_id; - let state_db_ctx = store.state_db().await; + let state_db = store.state_db(); let rollout_path = find_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), - state_db_ctx.as_deref(), + Some(state_db.as_ref()), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -52,11 +52,10 @@ pub(super) async fn archive_thread( } })?; - if let Some(ctx) = state_db_ctx { - let _ = ctx - .mark_archived(thread_id, archived_path.as_path(), Utc::now()) - .await; - } + let _ = store + .state_db() + .mark_archived(thread_id, archived_path.as_path(), Utc::now()) + .await; Ok(()) } @@ -75,13 +74,15 @@ mod tests { use crate::ThreadSortKey; use crate::ThreadStore; use crate::local::LocalThreadStore; + use crate::local::test_support::init_test_state_db; use crate::local::test_support::test_config; + use crate::local::test_support::test_store; use crate::local::test_support::write_session_file; #[tokio::test] async fn archive_thread_moves_rollout_to_archived_collection() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(201); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -127,21 +128,12 @@ mod tests { async fn archive_thread_updates_sqlite_metadata_when_present() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(202); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); - runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); let mut builder = codex_state::ThreadMetadataBuilder::new( thread_id, active_path.clone(), diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index e444e5c91d..44938e6e8e 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -22,18 +22,19 @@ pub(super) async fn create_thread( })?; let config = RolloutConfig { codex_home: store.config.codex_home.clone(), - sqlite_home: store.config.sqlite_home.clone(), + sqlite_home: store.sqlite_home(), cwd, model_provider_id: params.metadata.model_provider.clone(), generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled), }; - let state_db_ctx = store.state_db().await; + let state_db_ctx = Some(store.state_db()); let recorder = RolloutRecorder::new( &config, RolloutRecorderParams::new( params.thread_id, params.forked_from_id, params.source, + params.thread_source, params.base_instructions, params.dynamic_tools, event_persistence_mode(params.event_persistence_mode), diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 0cbf94da8c..bb46287123 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -130,6 +130,7 @@ pub(super) fn stored_thread_from_rollout_item( cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), source, + thread_source: None, agent_nickname: item.agent_nickname, agent_role: item.agent_role, agent_path: None, diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index e470ad2be9..5cf1f27aa1 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -39,16 +39,16 @@ pub(super) async fn list_threads( SortDirection::Asc => codex_rollout::SortDirection::Asc, SortDirection::Desc => codex_rollout::SortDirection::Desc, }; - let state_db = store.state_db().await; let rollout_config = RolloutConfig { codex_home: store.config.codex_home.clone(), - sqlite_home: store.config.sqlite_home.clone(), + sqlite_home: store.sqlite_home(), cwd: store.config.codex_home.clone(), model_provider_id: store.config.default_model_provider_id.clone(), generate_memories: false, }; + let state_db_ctx = Some(store.state_db()); let page = list_rollout_threads( - state_db, + state_db_ctx, &rollout_config, store.config.default_model_provider_id.as_str(), ¶ms, @@ -80,14 +80,13 @@ pub(super) async fn list_threads( .map(|thread| thread.thread_id) .collect::>(); let mut names = HashMap::::with_capacity(thread_ids.len()); - if let Some(state_db_ctx) = store.state_db().await { - for &thread_id in &thread_ids { - let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await else { - continue; - }; - if let Some(title) = distinct_thread_metadata_title(&metadata) { - names.insert(thread_id, title); - } + let state_db_ctx = store.state_db(); + for &thread_id in &thread_ids { + let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await else { + continue; + }; + if let Some(title) = distinct_thread_metadata_title(&metadata) { + names.insert(thread_id, title); } } if names.len() < thread_ids.len() @@ -108,9 +107,9 @@ pub(super) async fn list_threads( } async fn list_rollout_threads( - state_db: Option, + state_db_ctx: Option, config: &RolloutConfig, - default_model_provider_id: &str, + default_model_provider: &str, params: &ListThreadsParams, cursor: Option<&codex_rollout::Cursor>, sort_key: codex_rollout::ThreadSortKey, @@ -118,7 +117,7 @@ async fn list_rollout_threads( ) -> ThreadStoreResult { let page = if params.use_state_db_only && params.archived { RolloutRecorder::list_archived_threads_from_state_db( - state_db, + state_db_ctx.clone(), config, params.page_size, cursor, @@ -127,13 +126,13 @@ async fn list_rollout_threads( params.allowed_sources.as_slice(), params.model_providers.as_deref(), params.cwd_filters.as_deref(), - default_model_provider_id, + default_model_provider, params.search_term.as_deref(), ) .await } else if params.use_state_db_only { RolloutRecorder::list_threads_from_state_db( - state_db, + state_db_ctx.clone(), config, params.page_size, cursor, @@ -142,13 +141,13 @@ async fn list_rollout_threads( params.allowed_sources.as_slice(), params.model_providers.as_deref(), params.cwd_filters.as_deref(), - default_model_provider_id, + default_model_provider, params.search_term.as_deref(), ) .await } else if params.archived { RolloutRecorder::list_archived_threads( - state_db, + state_db_ctx.clone(), config, params.page_size, cursor, @@ -157,13 +156,13 @@ async fn list_rollout_threads( params.allowed_sources.as_slice(), params.model_providers.as_deref(), params.cwd_filters.as_deref(), - default_model_provider_id, + default_model_provider, params.search_term.as_deref(), ) .await } else { RolloutRecorder::list_threads( - state_db, + state_db_ctx, config, params.page_size, cursor, @@ -172,7 +171,7 @@ async fn list_rollout_threads( params.allowed_sources.as_slice(), params.model_providers.as_deref(), params.cwd_filters.as_deref(), - default_model_provider_id, + default_model_provider, params.search_term.as_deref(), ) .await @@ -195,7 +194,9 @@ mod tests { use super::*; use crate::ThreadStore; use crate::local::LocalThreadStore; + use crate::local::test_support::init_test_state_db; use crate::local::test_support::test_config; + use crate::local::test_support::test_store; use crate::local::test_support::write_archived_session_file; use crate::local::test_support::write_session_file; use crate::local::test_support::write_session_file_with; @@ -203,7 +204,7 @@ mod tests { #[tokio::test] async fn list_threads_uses_default_provider_when_rollout_omits_provider() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; write_session_file_with( home.path(), home.path().join("sessions/2025/01/03"), @@ -238,22 +239,13 @@ mod tests { async fn list_threads_preserves_sqlite_title_search_results() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(103); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = home.path().join("rollout-title-search.jsonl"); fs::write(&rollout_path, "").expect("placeholder rollout file"); - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); - runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); let created_at = Utc::now(); let mut builder = codex_state::ThreadMetadataBuilder::new( thread_id, @@ -267,6 +259,10 @@ mod tests { let mut metadata = builder.build(config.default_model_provider_id.as_str()); metadata.title = "needle title".to_string(); metadata.first_user_message = Some("plain preview".to_string()); + runtime + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); runtime .upsert_thread(&metadata) .await @@ -303,7 +299,7 @@ mod tests { #[tokio::test] async fn list_threads_selects_active_or_archived_collection() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let active_uuid = Uuid::from_u128(105); let archived_uuid = Uuid::from_u128(106); write_session_file(home.path(), "2025-01-03T12-00-00", active_uuid) @@ -372,7 +368,7 @@ mod tests { async fn list_threads_returns_local_rollout_summary() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config, /*state_db*/ None); + let store = LocalThreadStore::new(config.clone(), init_test_state_db(&config).await); let uuid = Uuid::from_u128(101); let path = write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); @@ -411,7 +407,7 @@ mod tests { #[tokio::test] async fn list_threads_rejects_invalid_cursor() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let err = store .list_threads(ListThreadsParams { diff --git a/codex-rs/thread-store/src/local/live_writer.rs b/codex-rs/thread-store/src/local/live_writer.rs index 643207b59d..37d9758973 100644 --- a/codex-rs/thread-store/src/local/live_writer.rs +++ b/codex-rs/thread-store/src/local/live_writer.rs @@ -66,12 +66,12 @@ pub(super) async fn resume_thread( })?; let config = RolloutConfig { codex_home: store.config.codex_home.clone(), - sqlite_home: store.config.sqlite_home.clone(), + sqlite_home: store.sqlite_home(), cwd, model_provider_id: params.metadata.model_provider.clone(), generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled), }; - let state_db_ctx = store.state_db().await; + let state_db_ctx = Some(store.state_db()); let recorder = RolloutRecorder::new( &config, RolloutRecorderParams::resume( diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 058c7b2309..8872442073 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -41,7 +41,7 @@ use crate::UpdateThreadMetadataParams; pub struct LocalThreadStore { pub(super) config: LocalThreadStoreConfig, live_recorders: Arc>>, - state_db: Option, + state_db: StateDbHandle, } /// Process-scoped configuration for local thread storage. @@ -51,7 +51,6 @@ pub struct LocalThreadStore { #[derive(Clone, Debug, PartialEq, Eq)] pub struct LocalThreadStoreConfig { pub codex_home: PathBuf, - pub sqlite_home: PathBuf, /// Provider used only when older local metadata does not contain one. pub default_model_provider_id: String, } @@ -60,7 +59,6 @@ impl LocalThreadStoreConfig { pub fn from_config(config: &impl codex_rollout::RolloutConfigView) -> Self { Self { codex_home: config.codex_home().to_path_buf(), - sqlite_home: config.sqlite_home().to_path_buf(), default_model_provider_id: config.model_provider_id().to_string(), } } @@ -75,8 +73,9 @@ impl std::fmt::Debug for LocalThreadStore { } impl LocalThreadStore { - /// Create a local store using an already initialized state DB handle. - pub fn new(config: LocalThreadStoreConfig, state_db: Option) -> Self { + /// Create a local store from process-scoped local storage configuration and + /// the caller-provided shared state DB handle. + pub fn new(config: LocalThreadStoreConfig, state_db: StateDbHandle) -> Self { Self { config, live_recorders: Arc::new(Mutex::new(HashMap::new())), @@ -85,10 +84,14 @@ impl LocalThreadStore { } /// Return the state DB handle used by local rollout writers. - pub async fn state_db(&self) -> Option { + pub fn state_db(&self) -> StateDbHandle { self.state_db.clone() } + pub(super) fn sqlite_home(&self) -> PathBuf { + self.state_db.codex_home().to_path_buf() + } + /// Read a local rollout-backed thread by path. pub async fn read_thread_by_rollout_path( &self, @@ -282,14 +285,16 @@ mod tests { use super::*; use crate::ThreadEventPersistenceMode; use crate::ThreadPersistenceMetadata; + use crate::local::test_support::init_test_state_db; use crate::local::test_support::test_config; + use crate::local::test_support::test_store; use crate::local::test_support::write_archived_session_file; use crate::local::test_support::write_session_file; #[tokio::test] async fn live_writer_lifecycle_writes_and_closes() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let thread_id = ThreadId::default(); store @@ -338,7 +343,7 @@ mod tests { #[tokio::test] async fn create_thread_rejects_missing_cwd() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let thread_id = ThreadId::default(); let mut params = create_thread_params(thread_id); params.metadata.cwd = None; @@ -358,7 +363,7 @@ mod tests { #[tokio::test] async fn discard_thread_drops_unmaterialized_live_writer() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let thread_id = ThreadId::default(); store @@ -396,8 +401,9 @@ mod tests { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); let thread_id = ThreadId::default(); + let state_db = init_test_state_db(&config).await; - let first_store = LocalThreadStore::new(config.clone(), /*state_db*/ None); + let first_store = LocalThreadStore::new(config.clone(), state_db.clone()); first_store .create_thread(create_thread_params(thread_id)) .await @@ -426,7 +432,7 @@ mod tests { .await .expect("shutdown initial writer"); - let resumed_store = LocalThreadStore::new(config, /*state_db*/ None); + let resumed_store = LocalThreadStore::new(config, state_db); resumed_store .resume_thread(ResumeThreadParams { thread_id, @@ -457,7 +463,7 @@ mod tests { #[tokio::test] async fn create_thread_rejects_duplicate_live_writer() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let thread_id = ThreadId::default(); store @@ -477,7 +483,7 @@ mod tests { #[tokio::test] async fn resume_thread_rejects_duplicate_live_writer() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let thread_id = ThreadId::default(); store @@ -506,7 +512,7 @@ mod tests { #[tokio::test] async fn resume_thread_rejects_missing_cwd() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = uuid::Uuid::from_u128(407); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = @@ -535,7 +541,7 @@ mod tests { async fn load_history_uses_live_writer_rollout_path() { let home = TempDir::new().expect("temp dir"); let external_home = TempDir::new().expect("external temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = uuid::Uuid::from_u128(404); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_session_file(external_home.path(), "2025-01-04T10-00-00", uuid) @@ -584,7 +590,7 @@ mod tests { async fn read_thread_uses_live_writer_rollout_path_for_external_resume() { let home = TempDir::new().expect("temp dir"); let external_home = TempDir::new().expect("external temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = uuid::Uuid::from_u128(406); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_session_file(external_home.path(), "2025-01-04T11-00-00", uuid) @@ -623,7 +629,7 @@ mod tests { #[tokio::test] async fn load_history_uses_live_writer_rollout_path_for_archived_source() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = uuid::Uuid::from_u128(405); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_archived_session_file(home.path(), "2025-01-04T10-30-00", uuid) @@ -691,7 +697,7 @@ mod tests { #[tokio::test] async fn read_thread_by_rollout_path_includes_history() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let thread_id = ThreadId::default(); store @@ -738,6 +744,7 @@ mod tests { thread_id, forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: thread_metadata(), diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 097277705f..dd6e8494ab 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -70,6 +70,11 @@ pub(super) async fn read_thread( })?; let mut thread = read_thread_from_rollout_path(store, path).await?; + if !params.include_archived && thread.archived_at.is_some() { + return Err(ThreadStoreError::InvalidRequest { + message: format!("thread {} is archived", thread.thread_id), + }); + } attach_history_if_requested(&mut thread, params.include_history).await?; Ok(thread) } @@ -171,12 +176,12 @@ async fn resolve_rollout_path( return Ok(Some(path)); } - let state_db_ctx = store.state_db().await; + let state_db = store.state_db(); if include_archived { match find_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), - state_db_ctx.as_deref(), + Some(state_db.as_ref()), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -186,7 +191,7 @@ async fn resolve_rollout_path( None => find_archived_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), - state_db_ctx.as_deref(), + Some(state_db.as_ref()), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -197,7 +202,7 @@ async fn resolve_rollout_path( find_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), - state_db_ctx.as_deref(), + Some(state_db.as_ref()), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -255,8 +260,7 @@ async fn read_sqlite_metadata( store: &LocalThreadStore, thread_id: codex_protocol::ThreadId, ) -> Option { - let runtime = store.state_db().await?; - runtime.get_thread(thread_id).await.ok().flatten() + store.state_db().get_thread(thread_id).await.ok().flatten() } async fn stored_thread_from_sqlite_metadata( @@ -270,10 +274,11 @@ async fn stored_thread_from_sqlite_metadata( .ok() .flatten(), }; - let forked_from_id = read_session_meta_line(metadata.rollout_path.as_path()) + let session_meta = read_session_meta_line(metadata.rollout_path.as_path()) .await .ok() - .and_then(|meta_line| meta_line.meta.forked_from_id); + .map(|meta_line| meta_line.meta); + let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id); StoredThread { thread_id: metadata.id, rollout_path: Some(metadata.rollout_path), @@ -293,6 +298,7 @@ async fn stored_thread_from_sqlite_metadata( cwd: metadata.cwd, cli_version: metadata.cli_version, source: parse_session_source(&metadata.source), + thread_source: metadata.thread_source, agent_nickname: metadata.agent_nickname, agent_role: metadata.agent_role, agent_path: metadata.agent_path, @@ -358,6 +364,7 @@ fn stored_thread_from_meta_line( cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, source: meta_line.meta.source, + thread_source: meta_line.meta.thread_source, agent_nickname: meta_line.meta.agent_nickname, agent_role: meta_line.meta.agent_role, agent_path: meta_line.meta.agent_path, @@ -407,7 +414,9 @@ mod tests { use super::*; use crate::ThreadStore; use crate::local::LocalThreadStore; + use crate::local::test_support::init_test_state_db; use crate::local::test_support::test_config; + use crate::local::test_support::test_store; use crate::local::test_support::write_archived_session_file; use crate::local::test_support::write_session_file; use crate::local::test_support::write_session_file_with_fork; @@ -415,7 +424,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_active_rollout_summary() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(205); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -443,7 +452,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_rollout_path_summary() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(211); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -474,17 +483,12 @@ mod tests { async fn read_thread_by_rollout_path_prefers_sqlite_git_info() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(223); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, active_path.clone(), @@ -522,7 +526,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_archived_rollout_when_requested() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(207); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T12-00-00", uuid) @@ -563,7 +567,7 @@ mod tests { #[tokio::test] async fn read_thread_prefers_active_rollout_over_archived() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(208); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -588,7 +592,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_forked_from_id() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(209); let parent_uuid = Uuid::from_u128(210); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); @@ -621,17 +625,12 @@ mod tests { async fn read_thread_applies_sqlite_thread_name() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(212); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli); builder.model_provider = Some(config.default_model_provider_id.clone()); @@ -661,13 +660,8 @@ mod tests { async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(224); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let day_dir = home.path().join("sessions/2025/01/03"); @@ -736,7 +730,7 @@ mod tests { #[tokio::test] async fn read_thread_uses_legacy_thread_name_when_sqlite_title_is_missing() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(213); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); @@ -760,6 +754,8 @@ mod tests { async fn read_thread_uses_sqlite_metadata_for_rollout_without_user_preview() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(217); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let day_dir = home.path().join("sessions/2025/01/03"); @@ -781,13 +777,6 @@ mod tests { }); writeln!(file, "{meta}").expect("write session meta"); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, rollout_path.clone(), @@ -830,18 +819,13 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(220); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); let stale_path = external.path().join("missing-rollout.jsonl"); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, stale_path.clone(), @@ -879,6 +863,8 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(221); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = @@ -886,13 +872,6 @@ mod tests { let other_uuid = Uuid::from_u128(222); let stale_path = write_session_file(external.path(), "2025-01-04T12-00-00", other_uuid) .expect("other session file"); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new(thread_id, stale_path, Utc::now(), SessionSource::Cli); builder.model_provider = Some("wrong-sqlite-provider".to_string()); @@ -924,7 +903,7 @@ mod tests { #[tokio::test] async fn read_thread_uses_session_meta_for_rollout_without_user_preview_or_sqlite_metadata() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(218); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let day_dir = home.path().join("sessions/2025/01/03"); @@ -979,18 +958,13 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(214); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = external .path() .join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl")); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, rollout_path.clone(), @@ -1037,20 +1011,15 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(216); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = external .path() .join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl")); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); let mut builder = ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); builder.archived_at = Some(Utc::now()); let mut metadata = builder.build(config.default_model_provider_id.as_str()); metadata.first_user_message = Some("Archived SQLite preview".to_string()); @@ -1093,17 +1062,12 @@ mod tests { async fn read_thread_sqlite_fallback_loads_archived_history() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(219); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T12-00-00", uuid) .expect("archived session file"); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, archived_path.clone(), @@ -1139,7 +1103,7 @@ mod tests { #[tokio::test] async fn read_thread_fails_without_rollout() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(206); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); diff --git a/codex-rs/thread-store/src/local/test_support.rs b/codex-rs/thread-store/src/local/test_support.rs index 98321880ff..597014efe9 100644 --- a/codex-rs/thread-store/src/local/test_support.rs +++ b/codex-rs/thread-store/src/local/test_support.rs @@ -4,18 +4,34 @@ use std::path::Path; use std::path::PathBuf; use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; +use codex_rollout::StateDbHandle; use uuid::Uuid; +use super::LocalThreadStore; use super::LocalThreadStoreConfig; pub(super) fn test_config(codex_home: &Path) -> LocalThreadStoreConfig { LocalThreadStoreConfig { codex_home: codex_home.to_path_buf(), - sqlite_home: codex_home.to_path_buf(), default_model_provider_id: "test-provider".to_string(), } } +pub(super) async fn init_test_state_db(config: &LocalThreadStoreConfig) -> StateDbHandle { + codex_state::StateRuntime::init( + config.codex_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize") +} + +pub(super) async fn test_store(codex_home: &Path) -> LocalThreadStore { + let config = test_config(codex_home); + let state_db = init_test_state_db(&config).await; + LocalThreadStore::new(config, state_db) +} + pub(super) fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result { write_session_file_with( root, diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index ad41db69ac..7ac3f01a0f 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -17,11 +17,11 @@ pub(super) async fn unarchive_thread( params: ArchiveThreadParams, ) -> ThreadStoreResult { let thread_id = params.thread_id; - let state_db_ctx = store.state_db().await; + let state_db = store.state_db(); let archived_path = find_archived_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), - state_db_ctx.as_deref(), + Some(state_db.as_ref()), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -73,11 +73,10 @@ pub(super) async fn unarchive_thread( message: format!("failed to update unarchived thread timestamp: {err}"), })?; - if let Some(ctx) = state_db_ctx { - let _ = ctx - .mark_unarchived(thread_id, restored_path.as_path()) - .await; - } + let _ = store + .state_db() + .mark_unarchived(thread_id, restored_path.as_path()) + .await; let item = read_thread_item_from_rollout(restored_path.clone()) .await @@ -112,13 +111,15 @@ mod tests { use super::*; use crate::ThreadStore; use crate::local::LocalThreadStore; + use crate::local::test_support::init_test_state_db; use crate::local::test_support::test_config; + use crate::local::test_support::test_store; use crate::local::test_support::write_archived_session_file; #[tokio::test] async fn unarchive_thread_restores_rollout_and_returns_updated_thread() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(203); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T13-00-00", uuid) @@ -149,21 +150,12 @@ mod tests { async fn unarchive_thread_updates_sqlite_metadata_when_present() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(204); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T13-00-00", uuid) .expect("archived session file"); - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); - runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); let mut builder = codex_state::ThreadMetadataBuilder::new( thread_id, archived_path.clone(), diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 677aa3fdce..15a7292124 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -1,10 +1,10 @@ +use std::path::Path; use std::path::PathBuf; use codex_protocol::ThreadId; -use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadMemoryMode; -use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; use codex_rollout::append_rollout_item_to_path; use codex_rollout::append_thread_name; @@ -13,7 +13,9 @@ use codex_rollout::find_thread_path_by_id_str; use codex_rollout::read_session_meta_line; use super::LocalThreadStore; +use super::helpers::git_info_from_parts; use super::live_writer; +use crate::GitInfoPatch; use crate::ReadThreadParams; use crate::StoredThread; use crate::ThreadStoreError; @@ -30,13 +32,10 @@ pub(super) async fn update_thread_metadata( store: &LocalThreadStore, params: UpdateThreadMetadataParams, ) -> ThreadStoreResult { - if params.patch.git_info.is_some() { - return Err(ThreadStoreError::Internal { - message: "local thread store does not implement git metadata updates in this slice" - .to_string(), - }); - } - if params.patch.name.is_some() && params.patch.memory_mode.is_some() { + let field_count = usize::from(params.patch.name.is_some()) + + usize::from(params.patch.memory_mode.is_some()) + + usize::from(params.patch.git_info.is_some()); + if field_count > 1 { return Err(ThreadStoreError::InvalidRequest { message: "local thread store applies one metadata field per patch in this slice" .to_string(), @@ -44,19 +43,20 @@ pub(super) async fn update_thread_metadata( } let thread_id = params.thread_id; + if live_writer::rollout_path(store, thread_id).await.is_ok() { + live_writer::persist_thread(store, thread_id).await?; + } let resolved_rollout_path = resolve_rollout_path(store, thread_id, params.include_archived).await?; - if let Some(name) = params.patch.name { - apply_thread_name(store, resolved_rollout_path.path.as_path(), thread_id, name).await?; - } + let name = params.patch.name; + let git_info = params.patch.git_info; if let Some(memory_mode) = params.patch.memory_mode { apply_thread_memory_mode(resolved_rollout_path.path.as_path(), thread_id, memory_mode) .await?; } - let state_db_ctx = store.state_db().await; codex_rollout::state_db::reconcile_rollout( - state_db_ctx.as_deref(), + Some(store.state_db()).as_deref(), resolved_rollout_path.path.as_path(), store.config.default_model_provider_id.as_str(), /*builder*/ None, @@ -66,7 +66,59 @@ pub(super) async fn update_thread_metadata( ) .await; - match read_thread::read_thread( + if let Some(name) = name { + apply_thread_name(store, thread_id, name).await?; + } + + let resolved_git_info = match git_info { + Some(git_info) => { + let state_db = store.state_db(); + let metadata = + state_db + .get_thread(thread_id) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!( + "failed to read git metadata for thread {thread_id}: {err}" + ), + })?; + let Some(metadata) = metadata else { + return Err(ThreadStoreError::Internal { + message: format!("thread metadata unavailable before git update: {thread_id}"), + }); + }; + let memory_mode = state_db + .get_thread_memory_mode(thread_id) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to read memory mode for thread {thread_id}: {err}"), + })?; + let existing_git_info = git_info_from_parts( + metadata.git_sha, + metadata.git_branch, + metadata.git_origin_url, + ); + Some(( + resolve_git_info_patch(existing_git_info, git_info), + memory_mode, + )) + } + None => None, + }; + if let Some(((sha, branch, origin_url), memory_mode)) = resolved_git_info.as_ref() { + apply_thread_git_info_to_rollout( + resolved_rollout_path.path.as_path(), + thread_id, + sha, + branch, + origin_url, + memory_mode.as_deref(), + ) + .await?; + apply_thread_git_info(store, thread_id, sha, branch, origin_url).await?; + } + + let mut thread = match read_thread::read_thread( store, ReadThreadParams { thread_id, @@ -76,7 +128,7 @@ pub(super) async fn update_thread_metadata( ) .await { - Ok(thread) => Ok(thread), + Ok(thread) => thread, Err(_) => { read_thread::read_thread_by_rollout_path( store, @@ -84,27 +136,115 @@ pub(super) async fn update_thread_metadata( params.include_archived, /*include_history*/ false, ) - .await + .await? } + }; + if let Some(((sha, branch, origin_url), _memory_mode)) = resolved_git_info { + thread.git_info = git_info_from_parts(sha, branch, origin_url); } + Ok(thread) +} + +async fn apply_thread_git_info( + store: &LocalThreadStore, + thread_id: ThreadId, + sha: &Option, + branch: &Option, + origin_url: &Option, +) -> ThreadStoreResult<()> { + let state_db = store.state_db(); + let updated = state_db + .update_thread_git_info( + thread_id, + Some(sha.as_deref()), + Some(branch.as_deref()), + Some(origin_url.as_deref()), + ) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to update git metadata for thread {thread_id}: {err}"), + })?; + if updated { + Ok(()) + } else { + Err(ThreadStoreError::Internal { + message: format!("thread metadata disappeared before update completed: {thread_id}"), + }) + } +} + +fn resolve_git_info_patch( + existing: Option, + git_info: GitInfoPatch, +) -> (Option, Option, Option) { + let (existing_sha, existing_branch, existing_origin_url) = match existing { + Some(info) => ( + info.commit_hash.map(|sha| sha.0), + info.branch, + info.repository_url, + ), + None => (None, None, None), + }; + let sha = git_info.sha.unwrap_or(existing_sha); + let branch = git_info.branch.unwrap_or(existing_branch); + let origin_url = git_info.origin_url.unwrap_or(existing_origin_url); + (sha, branch, origin_url) +} + +async fn apply_thread_git_info_to_rollout( + rollout_path: &Path, + thread_id: ThreadId, + sha: &Option, + branch: &Option, + origin_url: &Option, + memory_mode: Option<&str>, +) -> ThreadStoreResult<()> { + let mut session_meta = + read_session_meta_line(rollout_path) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to set thread git metadata: {err}"), + })?; + if session_meta.meta.id != thread_id { + return Err(ThreadStoreError::Internal { + message: format!( + "failed to set thread git metadata: rollout session metadata id mismatch: expected {thread_id}, found {}", + session_meta.meta.id + ), + }); + } + + session_meta.git = Some(GitInfo { + commit_hash: sha.as_deref().map(codex_git_utils::GitSha::new), + branch: branch.clone(), + repository_url: origin_url.clone(), + }); + session_meta.meta.memory_mode = memory_mode.map(str::to_string); + append_rollout_item_to_path(rollout_path, &RolloutItem::SessionMeta(session_meta)) + .await + .map_err(|err| ThreadStoreError::Internal { + message: format!("failed to set thread git metadata: {err}"), + }) } async fn apply_thread_name( store: &LocalThreadStore, - rollout_path: &std::path::Path, thread_id: ThreadId, name: String, ) -> ThreadStoreResult<()> { - let item = RolloutItem::EventMsg(EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { - thread_id, - thread_name: Some(name.clone()), - })); - - append_rollout_item_to_path(rollout_path, &item) + let updated = store + .state_db() + .update_thread_title(thread_id, &name) .await .map_err(|err| ThreadStoreError::Internal { message: format!("failed to set thread name: {err}"), })?; + if !updated { + return Err(ThreadStoreError::Internal { + message: format!("thread metadata unavailable before name update: {thread_id}"), + }); + } + append_thread_name(store.config.codex_home.as_path(), thread_id, &name) .await .map_err(|err| ThreadStoreError::Internal { @@ -113,7 +253,7 @@ async fn apply_thread_name( } async fn apply_thread_memory_mode( - rollout_path: &std::path::Path, + rollout_path: &Path, thread_id: ThreadId, memory_mode: ThreadMemoryMode, ) -> ThreadStoreResult<()> { @@ -132,6 +272,9 @@ async fn apply_thread_memory_mode( }); } + // Memory-mode updates should not modify git metadata. The rollout replay + // code will preserve the latest prior git marker when this field is absent. + session_meta.git = None; session_meta.meta.memory_mode = Some(memory_mode_as_str(memory_mode).to_string()); append_rollout_item_to_path(rollout_path, &RolloutItem::SessionMeta(session_meta)) .await @@ -157,11 +300,11 @@ async fn resolve_rollout_path( return Ok(ResolvedRolloutPath { path, archived }); } - let state_db_ctx = store.state_db().await; + let state_db = store.state_db(); let active_path = find_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), - state_db_ctx.as_deref(), + Some(state_db.as_ref()), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -181,7 +324,7 @@ async fn resolve_rollout_path( find_archived_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), - state_db_ctx.as_deref(), + Some(state_db.as_ref()), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -196,7 +339,7 @@ async fn resolve_rollout_path( }) } -fn rollout_path_is_archived(store: &LocalThreadStore, path: &std::path::Path) -> bool { +fn rollout_path_is_archived(store: &LocalThreadStore, path: &Path) -> bool { path.starts_with(store.config.codex_home.join(ARCHIVED_SESSIONS_SUBDIR)) } @@ -204,28 +347,31 @@ fn rollout_path_is_archived(store: &LocalThreadStore, path: &std::path::Path) -> mod tests { use pretty_assertions::assert_eq; use serde_json::Value; + use serde_json::json; use tempfile::TempDir; use uuid::Uuid; use super::*; + use crate::GitInfoPatch; use crate::ResumeThreadParams; use crate::ThreadEventPersistenceMode; use crate::ThreadMetadataPatch; use crate::ThreadPersistenceMetadata; use crate::ThreadStore; use crate::local::LocalThreadStore; + use crate::local::test_support::init_test_state_db; use crate::local::test_support::test_config; + use crate::local::test_support::test_store; use crate::local::test_support::write_archived_session_file; use crate::local::test_support::write_session_file; #[tokio::test] async fn update_thread_metadata_sets_name_on_active_rollout_and_indexes_name() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(301); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); - let path = - write_session_file(home.path(), "2025-01-03T14-00-00", uuid).expect("session file"); + write_session_file(home.path(), "2025-01-03T14-00-00", uuid).expect("session file"); let thread = store .update_thread_metadata(UpdateThreadMetadataParams { @@ -245,29 +391,25 @@ mod tests { .expect("find thread name"); assert_eq!(latest_name.as_deref(), Some("A sharper name")); - let appended = last_rollout_item(path.as_path()); - assert_eq!(appended["type"], "event_msg"); - assert_eq!(appended["payload"]["type"], "thread_name_updated"); - assert_eq!(appended["payload"]["thread_id"], thread_id.to_string()); - assert_eq!(appended["payload"]["thread_name"], "A sharper name"); + let metadata = store + .state_db() + .get_thread(thread_id) + .await + .expect("get metadata") + .expect("metadata"); + assert_eq!(metadata.title, "A sharper name"); } #[tokio::test] async fn update_thread_metadata_sets_memory_mode_on_active_rollout() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(302); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = write_session_file(home.path(), "2025-01-03T14-30-00", uuid).expect("session file"); - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); - let thread = store .update_thread_metadata(UpdateThreadMetadataParams { thread_id, @@ -292,11 +434,75 @@ mod tests { assert_eq!(memory_mode.as_deref(), Some("disabled")); } + #[tokio::test] + async fn update_thread_metadata_preserves_memory_mode_when_updating_git_info() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let uuid = Uuid::from_u128(312); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let path = + write_session_file(home.path(), "2025-01-03T18-30-00", uuid).expect("session file"); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + memory_mode: Some(ThreadMemoryMode::Disabled), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set memory mode"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + branch: Some(Some("feature".to_string())), + ..Default::default() + }), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set git metadata"); + + assert_eq!( + thread.git_info.expect("git info").branch.as_deref(), + Some("feature") + ); + let appended = last_rollout_item(path.as_path()); + assert_eq!(appended["type"], "session_meta"); + assert_eq!(appended["payload"]["memory_mode"], "disabled"); + assert_eq!(appended["payload"]["git"]["branch"], "feature"); + + codex_rollout::state_db::reconcile_rollout( + Some(runtime.as_ref()), + path.as_path(), + config.default_model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + let memory_mode = runtime + .get_thread_memory_mode(thread_id) + .await + .expect("thread memory mode should be readable"); + assert_eq!(memory_mode.as_deref(), Some("disabled")); + } + #[tokio::test] async fn update_thread_metadata_uses_live_rollout_path_for_external_resume() { let home = TempDir::new().expect("temp dir"); let external_home = TempDir::new().expect("external temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(307); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = write_session_file(external_home.path(), "2025-01-03T14-45-00", uuid) @@ -333,10 +539,267 @@ mod tests { assert_eq!(appended["payload"]["memory_mode"], "disabled"); } + #[tokio::test] + async fn update_thread_metadata_sets_git_info() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config, runtime); + let uuid = Uuid::from_u128(309); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file(home.path(), "2025-01-03T17-00-00", uuid).expect("session file"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + sha: Some(Some("abc123".to_string())), + branch: Some(Some("main".to_string())), + origin_url: Some(Some("https://github.com/openai/codex".to_string())), + }), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set git metadata"); + + let git_info = thread.git_info.expect("git info should be present"); + assert_eq!( + git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), + Some("abc123") + ); + assert_eq!(git_info.branch.as_deref(), Some("main")); + assert_eq!( + git_info.repository_url.as_deref(), + Some("https://github.com/openai/codex") + ); + } + + #[tokio::test] + async fn update_thread_metadata_partially_updates_git_info() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config, runtime); + let uuid = Uuid::from_u128(310); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file(home.path(), "2025-01-03T17-30-00", uuid).expect("session file"); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + sha: Some(Some("abc123".to_string())), + branch: Some(Some("main".to_string())), + origin_url: Some(Some("https://github.com/openai/codex".to_string())), + }), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("seed git metadata"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + branch: Some(Some("feature".to_string())), + ..Default::default() + }), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("partially update git metadata"); + + let git_info = thread.git_info.expect("git info should be present"); + assert_eq!( + git_info.commit_hash.as_ref().map(|sha| sha.0.as_str()), + Some("abc123") + ); + assert_eq!(git_info.branch.as_deref(), Some("feature")); + assert_eq!( + git_info.repository_url.as_deref(), + Some("https://github.com/openai/codex") + ); + } + + #[tokio::test] + async fn update_thread_metadata_clears_git_info_fields() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); + let uuid = Uuid::from_u128(311); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let path = + write_session_file(home.path(), "2025-01-03T18-00-00", uuid).expect("session file"); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + sha: Some(Some("abc123".to_string())), + branch: Some(Some("main".to_string())), + origin_url: Some(Some("https://github.com/openai/codex".to_string())), + }), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("seed git metadata"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + sha: Some(None), + branch: Some(None), + origin_url: Some(None), + }), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("clear git metadata"); + + assert!(thread.git_info.is_none()); + let appended = last_rollout_item(path.as_path()); + assert_eq!(appended["type"], "session_meta"); + assert_eq!(appended["payload"]["git"], json!({})); + + codex_rollout::state_db::reconcile_rollout( + Some(runtime.as_ref()), + path.as_path(), + config.default_model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + let thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }) + .await + .expect("read thread after reconcile"); + assert!(thread.git_info.is_none()); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + memory_mode: Some(ThreadMemoryMode::Disabled), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set memory mode after git clear"); + let appended = last_rollout_item(path.as_path()); + assert_eq!(appended["type"], "session_meta"); + assert_eq!(appended["payload"].get("git"), None); + codex_rollout::state_db::reconcile_rollout( + Some(runtime.as_ref()), + path.as_path(), + config.default_model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + let thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }) + .await + .expect("read thread after memory mode update with no git"); + assert!(thread.git_info.is_none()); + + assert_eq!( + runtime + .delete_thread(thread_id) + .await + .expect("delete sqlite thread row"), + 1 + ); + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + branch: Some(Some("feature".to_string())), + ..Default::default() + }), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("partially update after clear with missing sqlite row"); + let git_info = thread.git_info.expect("branch should be present"); + assert_eq!(git_info.commit_hash, None); + assert_eq!(git_info.branch.as_deref(), Some("feature")); + assert_eq!(git_info.repository_url, None); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + memory_mode: Some(ThreadMemoryMode::Disabled), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set memory mode after git clear and partial update"); + let appended = last_rollout_item(path.as_path()); + assert_eq!(appended["type"], "session_meta"); + assert_eq!(appended["payload"].get("git"), None); + codex_rollout::state_db::reconcile_rollout( + Some(runtime.as_ref()), + path.as_path(), + config.default_model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + let thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }) + .await + .expect("read thread after memory mode update"); + let git_info = thread.git_info.expect("branch should remain present"); + assert_eq!(git_info.commit_hash, None); + assert_eq!(git_info.branch.as_deref(), Some("feature")); + assert_eq!(git_info.repository_url, None); + } + #[tokio::test] async fn update_thread_metadata_rejects_mismatched_session_meta_id() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let filename_uuid = Uuid::from_u128(303); let metadata_uuid = Uuid::from_u128(304); let thread_id = ThreadId::from_string(&filename_uuid.to_string()).expect("valid thread id"); @@ -368,7 +831,7 @@ mod tests { #[tokio::test] async fn update_thread_metadata_rejects_multi_field_patch_without_partial_write() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); + let store = test_store(home.path()).await; let uuid = Uuid::from_u128(305); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = @@ -403,21 +866,12 @@ mod tests { async fn update_thread_metadata_keeps_archived_thread_archived_in_sqlite() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(306); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-00-00", uuid) .expect("archived session file"); - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); - runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); codex_rollout::state_db::reconcile_rollout( Some(runtime.as_ref()), archived_path.as_path(), @@ -466,21 +920,12 @@ mod tests { async fn update_thread_metadata_keeps_live_archived_thread_archived_in_sqlite() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let runtime = init_test_state_db(&config).await; + let store = LocalThreadStore::new(config.clone(), runtime.clone()); let uuid = Uuid::from_u128(308); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-30-00", uuid) .expect("archived session file"); - let runtime = codex_state::StateRuntime::init( - home.path().to_path_buf(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); - runtime - .mark_backfill_complete(/*last_watermark*/ None) - .await - .expect("backfill should be complete"); codex_rollout::state_db::reconcile_rollout( Some(runtime.as_ref()), archived_path.as_path(), diff --git a/codex-rs/thread-store/src/remote/helpers.rs b/codex-rs/thread-store/src/remote/helpers.rs index 74b3ac7763..3322ecd142 100644 --- a/codex-rs/thread-store/src/remote/helpers.rs +++ b/codex-rs/thread-store/src/remote/helpers.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::ThreadSource; use super::proto; use crate::GitInfoPatch; @@ -296,6 +297,11 @@ pub(super) fn stored_thread_from_proto( cwd: PathBuf::from(thread.cwd), cli_version: thread.cli_version, source, + thread_source: thread + .thread_source + .map(|thread_source| thread_source.parse::()) + .transpose() + .map_err(|error| ThreadStoreError::Internal { message: error })?, agent_nickname: thread.agent_nickname, agent_role: thread.agent_role, agent_path: thread.agent_path, @@ -340,6 +346,7 @@ pub(super) fn stored_thread_to_proto(thread: StoredThread) -> proto::StoredThrea cwd: thread.cwd.to_string_lossy().into_owned(), cli_version: thread.cli_version, source: Some(proto_session_source(&thread.source)), + thread_source: thread.thread_source.map(|source| source.to_string()), git_info: thread.git_info.map(git_info_to_proto), agent_nickname: thread.agent_nickname, agent_role: thread.agent_role, diff --git a/codex-rs/thread-store/src/remote/list_threads.rs b/codex-rs/thread-store/src/remote/list_threads.rs index 7fb0da6dc3..cf562497f4 100644 --- a/codex-rs/thread-store/src/remote/list_threads.rs +++ b/codex-rs/thread-store/src/remote/list_threads.rs @@ -140,6 +140,7 @@ mod tests { kind: proto::SessionSourceKind::Cli.into(), ..Default::default() }), + thread_source: Some("user".to_string()), git_info: Some(proto::GitInfo { sha: Some("abc123".to_string()), branch: Some("main".to_string()), @@ -250,6 +251,7 @@ mod tests { sub_agent_role: Some("explorer".to_string()), ..Default::default() }), + thread_source: Some("subagent".to_string()), git_info: Some(proto::GitInfo { sha: Some("abc123".to_string()), branch: Some("main".to_string()), diff --git a/codex-rs/thread-store/src/remote/mod.rs b/codex-rs/thread-store/src/remote/mod.rs index 3e74a45f4b..013b74c933 100644 --- a/codex-rs/thread-store/src/remote/mod.rs +++ b/codex-rs/thread-store/src/remote/mod.rs @@ -358,6 +358,7 @@ mod tests { thread_id: ThreadId::new(), forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: metadata.clone(), diff --git a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto index 7c797f139a..a5755afee0 100644 --- a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto +++ b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto @@ -132,6 +132,7 @@ message StoredThread { optional string sandbox_policy_json = 21; optional string token_usage_json = 22; optional StoredThreadHistory history = 23; + optional string thread_source = 24; } message SessionSource { diff --git a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs index a210ef8766..c5b8fdc1d6 100644 --- a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs +++ b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs @@ -168,6 +168,8 @@ pub struct StoredThread { pub token_usage_json: ::core::option::Option<::prost::alloc::string::String>, #[prost(message, optional, tag = "23")] pub history: ::core::option::Option, + #[prost(string, optional, tag = "24")] + pub thread_source: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct SessionSource { diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 85bde023bd..06aa2998c9 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use serde::Deserialize; use serde::Serialize; @@ -48,6 +49,8 @@ pub struct CreateThreadParams { pub forked_from_id: Option, /// Runtime source for the thread. pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Base instructions persisted in session metadata. pub base_instructions: BaseInstructions, /// Dynamic tools available to the thread at startup. @@ -211,6 +214,8 @@ pub struct StoredThread { pub cli_version: String, /// Runtime source for the thread. pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Optional random nickname for thread-spawn sub-agents. pub agent_nickname: Option, /// Optional role for thread-spawn sub-agents. diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index b85f8b2198..9fa9b4543f 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -22,7 +22,7 @@ schema and Responses API tool primitives that no longer need to live in - `ResponsesApiNamespace` - `ResponsesApiNamespaceTool` - code-mode `ToolSpec` adapters and `exec` / `wait` spec builders -- MCP resource, `list_dir`, and `test_sync_tool` spec builders +- MCP resource and `test_sync_tool` spec builders - local host tool spec builders for shell/exec/request-permissions/view-image - collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close, `request_user_input`, and CSV fanout/reporting diff --git a/codex-rs/tools/src/agent_tool_tests.rs b/codex-rs/tools/src/agent_tool_tests.rs index 3157cfc547..38391cdbbf 100644 --- a/codex-rs/tools/src/agent_tool_tests.rs +++ b/codex-rs/tools/src/agent_tool_tests.rs @@ -20,6 +20,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { }], supports_personality: false, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), is_default: false, upgrade: None, show_in_picker, diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 387d0394a0..ebe54c382b 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -150,7 +150,6 @@ pub use tool_spec::create_image_generation_tool; pub use tool_spec::create_local_shell_tool; pub use tool_spec::create_tools_json_for_responses_api; pub use tool_spec::create_web_search_tool; -pub use utility_tool::create_list_dir_tool; pub use utility_tool::create_test_sync_tool; pub use view_image::ViewImageToolOptions; pub use view_image::create_view_image_tool; diff --git a/codex-rs/tools/src/local_tool.rs b/codex-rs/tools/src/local_tool.rs index ed4080d5f4..aeabdbfa30 100644 --- a/codex-rs/tools/src/local_tool.rs +++ b/codex-rs/tools/src/local_tool.rs @@ -17,6 +17,13 @@ pub struct ShellToolOptions { } pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec { + create_exec_command_tool_with_environment_id(options, /*include_environment_id*/ false) +} + +pub(crate) fn create_exec_command_tool_with_environment_id( + options: CommandToolOptions, + include_environment_id: bool, +) -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -63,6 +70,14 @@ pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec { )), ); } + if include_environment_id { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional environment id from the block. If omitted, uses the primary environment.".to_string(), + )), + ); + } properties.extend(create_approval_parameters( options.exec_permission_approvals_enabled, )); diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 8fa257b1ec..c777bd4129 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -7,6 +7,7 @@ use crate::ShellToolOptions; use crate::SpawnAgentToolOptions; use crate::TOOL_SEARCH_DEFAULT_LIMIT; use crate::TOOL_SEARCH_TOOL_NAME; +use crate::ToolEnvironmentMode; use crate::ToolHandlerKind; use crate::ToolName; use crate::ToolRegistryPlan; @@ -27,12 +28,10 @@ use crate::create_close_agent_tool_v1; use crate::create_close_agent_tool_v2; use crate::create_code_mode_tool; use crate::create_create_goal_tool; -use crate::create_exec_command_tool; use crate::create_followup_task_tool; use crate::create_get_goal_tool; use crate::create_image_generation_tool; use crate::create_list_agents_tool; -use crate::create_list_dir_tool; use crate::create_list_mcp_resource_templates_tool; use crate::create_list_mcp_resources_tool; use crate::create_local_shell_tool; @@ -61,6 +60,7 @@ use crate::create_web_search_tool; use crate::create_write_stdin_tool; use crate::default_namespace_description; use crate::dynamic_tool_to_loadable_tool_spec; +use crate::local_tool::create_exec_command_tool_with_environment_id; use crate::mcp_tool_to_responses_api_tool; use crate::request_permissions_tool_description; use crate::request_user_input_tool_description; @@ -136,6 +136,8 @@ pub fn build_tool_registry_plan( } if config.environment_mode.has_environment() { + let include_environment_id = + matches!(config.environment_mode, ToolEnvironmentMode::Multiple); match &config.shell_type { ConfigShellToolType::Default => { plan.push_spec( @@ -155,10 +157,13 @@ pub fn build_tool_registry_plan( } ConfigShellToolType::UnifiedExec => { plan.push_spec( - create_exec_command_tool(CommandToolOptions { - allow_login_shell: config.allow_login_shell, - exec_permission_approvals_enabled, - }), + create_exec_command_tool_with_environment_id( + CommandToolOptions { + allow_login_shell: config.allow_login_shell, + exec_permission_approvals_enabled, + }, + include_environment_id, + ), /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, ); @@ -167,8 +172,8 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("exec_command", ToolHandlerKind::UnifiedExec); - plan.register_handler("write_stdin", ToolHandlerKind::UnifiedExec); + plan.register_handler("exec_command", ToolHandlerKind::ExecCommand); + plan.register_handler("write_stdin", ToolHandlerKind::WriteStdin); } ConfigShellToolType::Disabled => {} ConfigShellToolType::ShellCommand => { @@ -188,8 +193,8 @@ pub fn build_tool_registry_plan( && config.shell_type != ConfigShellToolType::Disabled { plan.register_handler("shell", ToolHandlerKind::Shell); - plan.register_handler("container.exec", ToolHandlerKind::Shell); - plan.register_handler("local_shell", ToolHandlerKind::Shell); + plan.register_handler("container.exec", ToolHandlerKind::ContainerExec); + plan.register_handler("local_shell", ToolHandlerKind::LocalShell); plan.register_handler("shell_command", ToolHandlerKind::ShellCommand); } @@ -209,9 +214,12 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, ); - plan.register_handler("list_mcp_resources", ToolHandlerKind::McpResource); - plan.register_handler("list_mcp_resource_templates", ToolHandlerKind::McpResource); - plan.register_handler("read_mcp_resource", ToolHandlerKind::McpResource); + plan.register_handler("list_mcp_resources", ToolHandlerKind::ListMcpResources); + plan.register_handler( + "list_mcp_resource_templates", + ToolHandlerKind::ListMcpResourceTemplates, + ); + plan.register_handler("read_mcp_resource", ToolHandlerKind::ReadMcpResource); } plan.push_spec( @@ -226,19 +234,19 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("get_goal", ToolHandlerKind::Goal); + plan.register_handler("get_goal", ToolHandlerKind::GetGoal); plan.push_spec( create_create_goal_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("create_goal", ToolHandlerKind::Goal); + plan.register_handler("create_goal", ToolHandlerKind::CreateGoal); plan.push_spec( create_update_goal_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("update_goal", ToolHandlerKind::Goal); + plan.register_handler("update_goal", ToolHandlerKind::UpdateGoal); } plan.push_spec( @@ -348,20 +356,6 @@ pub fn build_tool_registry_plan( plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch); } - if config.environment_mode.has_environment() - && config - .experimental_supported_tools - .iter() - .any(|tool| tool == "list_dir") - { - plan.push_spec( - create_list_dir_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - plan.register_handler("list_dir", ToolHandlerKind::ListDir); - } - if config .experimental_supported_tools .iter() @@ -502,14 +496,17 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("spawn_agents_on_csv", ToolHandlerKind::AgentJobs); + plan.register_handler("spawn_agents_on_csv", ToolHandlerKind::SpawnAgentsOnCsv); if config.agent_jobs_worker_tools { plan.push_spec( create_report_agent_job_result_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("report_agent_job_result", ToolHandlerKind::AgentJobs); + plan.register_handler( + "report_agent_job_result", + ToolHandlerKind::ReportAgentJobResult, + ); } } diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 6f48a9e3b5..fe5507ddc7 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -19,6 +19,7 @@ use crate::ToolRegistryPlanDeferredTool; use crate::ToolRegistryPlanMcpTool; use crate::ToolsConfigParams; use crate::WaitAgentTimeoutOptions; +use crate::create_exec_command_tool; use crate::mcp_call_tool_result_output_schema; use crate::request_user_input_available_modes; use codex_app_server_protocol::AppInfo; @@ -159,6 +160,49 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { } } +#[test] +fn exec_command_spec_includes_environment_id_only_for_multiple_selected_environments() { + let model_info = model_info(); + let available_models = Vec::new(); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + let config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let (single_environment_tools, _) = build_specs( + &config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + assert_process_tool_environment_id( + &single_environment_tools, + "exec_command", + /*expected_present*/ false, + ); + + let multi_environment_config = config.with_environment_mode(ToolEnvironmentMode::Multiple); + let (multi_environment_tools, _) = build_specs( + &multi_environment_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + assert_process_tool_environment_id( + &multi_environment_tools, + "exec_command", + /*expected_present*/ true, + ); +} + #[test] fn test_build_specs_collab_tools_enabled() { let model_info = model_info(); @@ -535,7 +579,7 @@ fn disabled_environment_omits_environment_backed_tools() { let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); let available_models = Vec::new(); - let mut tools_config = ToolsConfig::new(&ToolsConfigParams { + let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &available_models, features: &features, @@ -546,9 +590,6 @@ fn disabled_environment_omits_environment_backed_tools() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }) .with_environment_mode(ToolEnvironmentMode::None); - tools_config - .experimental_supported_tools - .push("list_dir".to_string()); let (tools, _) = build_specs( &tools_config, /*mcp_tools*/ None, @@ -559,7 +600,6 @@ fn disabled_environment_omits_environment_backed_tools() { assert_lacks_tool_name(&tools, "exec_command"); assert_lacks_tool_name(&tools, "write_stdin"); assert_lacks_tool_name(&tools, "apply_patch"); - assert_lacks_tool_name(&tools, "list_dir"); assert_lacks_tool_name(&tools, VIEW_IMAGE_TOOL_NAME); } @@ -2428,6 +2468,23 @@ fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a Co .unwrap_or_else(|| panic!("expected tool {expected_name}")) } +fn assert_process_tool_environment_id( + tools: &[ConfiguredToolSpec], + expected_name: &str, + expected_present: bool, +) { + let tool = find_tool(tools, expected_name); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &tool.spec else { + panic!("expected function tool {expected_name}"); + }; + let (properties, _) = expect_object_schema(parameters); + assert_eq!( + properties.contains_key("environment_id"), + expected_present, + "{expected_name} environment_id parameter presence" + ); +} + fn find_namespace_function_tool<'a>( tools: &'a [ConfiguredToolSpec], expected_namespace: &str, diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index 7c3772783d..0212cb53d4 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -10,20 +10,26 @@ use std::collections::HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolHandlerKind { - AgentJobs, ApplyPatch, CloseAgentV1, CloseAgentV2, CodeModeExecute, CodeModeWait, + ContainerExec, + CreateGoal, DynamicTool, + ExecCommand, FollowupTaskV2, - Goal, + GetGoal, ListAgentsV2, - ListDir, + ListMcpResourceTemplates, + ListMcpResources, + LocalShell, Mcp, - McpResource, Plan, + ReadMcpResource, + ReportAgentJobResult, + RequestPluginInstall, RequestPermissions, RequestUserInput, ResumeAgentV1, @@ -31,15 +37,16 @@ pub enum ToolHandlerKind { SendMessageV2, Shell, ShellCommand, + SpawnAgentsOnCsv, SpawnAgentV1, SpawnAgentV2, TestSync, ToolSearch, - RequestPluginInstall, - UnifiedExec, + UpdateGoal, ViewImage, WaitAgentV1, WaitAgentV2, + WriteStdin, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/tools/src/utility_tool.rs b/codex-rs/tools/src/utility_tool.rs index b0f93c9726..0465a043ef 100644 --- a/codex-rs/tools/src/utility_tool.rs +++ b/codex-rs/tools/src/utility_tool.rs @@ -3,42 +3,6 @@ use crate::ResponsesApiTool; use crate::ToolSpec; use std::collections::BTreeMap; -pub fn create_list_dir_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "dir_path".to_string(), - JsonSchema::string(Some("Absolute path to the directory to list.".to_string())), - ), - ( - "offset".to_string(), - JsonSchema::number(Some( - "The entry number to start listing from. Must be 1 or greater.".to_string(), - )), - ), - ( - "limit".to_string(), - JsonSchema::number(Some("The maximum number of entries to return.".to_string())), - ), - ( - "depth".to_string(), - JsonSchema::number(Some( - "The maximum directory depth to traverse. Must be 1 or greater.".to_string(), - )), - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "list_dir".to_string(), - description: - "Lists entries in a local directory with 1-indexed entry numbers and simple type labels." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(properties, Some(vec!["dir_path".to_string()]), Some(false.into())), - output_schema: None, - }) -} - pub fn create_test_sync_tool() -> ToolSpec { let barrier_properties = BTreeMap::from([ ( diff --git a/codex-rs/tools/src/utility_tool_tests.rs b/codex-rs/tools/src/utility_tool_tests.rs index 2984d02f48..97280315b8 100644 --- a/codex-rs/tools/src/utility_tool_tests.rs +++ b/codex-rs/tools/src/utility_tool_tests.rs @@ -3,50 +3,6 @@ use crate::JsonSchema; use pretty_assertions::assert_eq; use std::collections::BTreeMap; -#[test] -fn list_dir_tool_matches_expected_spec() { - assert_eq!( - create_list_dir_tool(), - ToolSpec::Function(ResponsesApiTool { - name: "list_dir".to_string(), - description: - "Lists entries in a local directory with 1-indexed entry numbers and simple type labels." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(BTreeMap::from([ - ( - "depth".to_string(), - JsonSchema::number(Some( - "The maximum directory depth to traverse. Must be 1 or greater." - .to_string(), - )), - ), - ( - "dir_path".to_string(), - JsonSchema::string(Some( - "Absolute path to the directory to list.".to_string(), - )), - ), - ( - "limit".to_string(), - JsonSchema::number(Some( - "The maximum number of entries to return.".to_string(), - )), - ), - ( - "offset".to_string(), - JsonSchema::number(Some( - "The entry number to start listing from. Must be 1 or greater." - .to_string(), - )), - ), - ]), Some(vec!["dir_path".to_string()]), Some(false.into())), - output_schema: None, - }) - ); -} - #[test] fn test_sync_tool_matches_expected_spec() { assert_eq!( diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c5538c02ed..5ef87bd9b9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,6 +42,7 @@ codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-message-history = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 66c5dfd184..d6d65b04a4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -17,6 +17,7 @@ use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::app_server_rate_limit_snapshots; +use crate::bottom_pane::AppLinkViewParams; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -41,13 +42,11 @@ use crate::history_cell::HistoryCell; use crate::history_cell::UpdateAvailableHistoryCell; use crate::key_hint::KeyBindingListExt; use crate::keymap::RuntimeKeymap; -use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; -use crate::legacy_core::lookup_message_history_entry; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::model_catalog::ModelCatalog; @@ -219,6 +218,7 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; enum ThreadInteractiveRequest { + AppLink(AppLinkViewParams), Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), } @@ -953,6 +953,7 @@ See the Codex keymap documentation for supported actions and examples." tui.frame_requester().schedule_frame(); app.refresh_startup_skills(&app_server); + app.refresh_startup_hooks(&app_server); // Kick off a non-blocking rate-limit prefetch so the first `/status` // already has data, without delaying the initial frame render. if requires_openai_auth && has_chatgpt_account { diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 36155fb339..3233db8276 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -5,6 +5,7 @@ //! the main event loop remains single-threaded. use super::*; +use codex_app_server_protocol::HookTrustStatus; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveParams; @@ -88,6 +89,47 @@ impl App { }); } + /// Emits the initial hook review warning without delaying the first interactive frame. + pub(super) fn refresh_startup_hooks(&mut self, app_server: &AppServerSession) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + let cwd = self.config.cwd.to_path_buf(); + tokio::spawn(async move { + let result = fetch_hooks_list(request_handle, cwd.clone()).await; + let response = match result { + Ok(response) => response, + Err(err) => { + tracing::warn!("failed to load startup hook review state: {err:#}"); + return; + } + }; + let hooks_needing_review = response + .data + .into_iter() + .find(|entry| entry.cwd.as_path() == cwd.as_path()) + .map(|entry| { + entry + .hooks + .into_iter() + .filter(|hook| { + matches!( + hook.trust_status, + HookTrustStatus::Untrusted | HookTrustStatus::Modified + ) + }) + .count() + }) + .unwrap_or_default(); + if let Some(message) = + startup_prompts::hooks_needing_review_warning(hooks_needing_review) + { + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_warning_event(message), + ))); + } + }); + } + pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); @@ -322,6 +364,23 @@ impl App { }); } + pub(super) fn trust_hook( + &mut self, + app_server: &AppServerSession, + key: String, + current_hash: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = write_hook_trust(request_handle, key, current_hash) + .await + .map(|_| ()) + .map_err(|err| format!("Failed to trust hook: {err}")); + app_event_tx.send(AppEvent::HookTrusted { result }); + }); + } + pub(super) fn refresh_plugin_mentions(&mut self) { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); @@ -805,6 +864,35 @@ pub(super) async fn write_hook_enabled( .wrap_err("config/batchWrite failed while updating hook enablement in TUI") } +pub(super) async fn write_hook_trust( + request_handle: AppServerRequestHandle, + key: String, + current_hash: String, +) -> Result { + let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4())); + let value = serde_json::json!({ + key: { + "trusted_hash": current_hash, + } + }); + request_handle + .request_typed(ClientRequest::ConfigBatchWrite { + request_id, + params: ConfigBatchWriteParams { + edits: vec![codex_app_server_protocol::ConfigEdit { + key_path: "hooks.state".to_string(), + value, + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }, + }) + .await + .wrap_err("config/batchWrite failed while updating hook trust in TUI") +} + pub(super) fn build_feedback_upload_params( origin_thread_id: Option, rollout_path: Option, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 69eba70cf9..e50c2782ca 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -648,8 +648,7 @@ mod tests { cwd: next_cwd.clone().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index aefab7a1b3..bfe8dc4b24 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -35,6 +35,9 @@ impl App { ) .await; } + AppEvent::RawOutputModeChanged { enabled } => { + self.apply_raw_output_mode(tui, enabled, /*notify*/ false); + } AppEvent::ClearUiAndSubmitUserMessage { text } => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); @@ -74,7 +77,7 @@ impl App { return Ok(AppRunControl::Continue); } }; - match crate::resume_picker::run_resume_picker_with_app_server( + match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server( tui, &self.config, /*show_all*/ false, @@ -94,9 +97,13 @@ impl App { } } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Fork(_) => {} + SessionSelection::Exit | SessionSelection::StartFresh => { + self.refresh_in_memory_config_from_disk_best_effort( + "closing the session picker", + ) + .await; + } + SessionSelection::Fork(_) => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. @@ -319,6 +326,17 @@ impl App { AppEvent::CodexOp(op) => { self.submit_active_thread_op(app_server, op).await?; } + AppEvent::AppendMessageHistoryEntry { thread_id, text } => { + self.append_message_history_entry(thread_id, text); + } + AppEvent::LookupMessageHistoryEntry { + thread_id, + offset, + log_id, + } => { + self.lookup_message_history_entry(thread_id, offset, log_id) + .await?; + } AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => { self.chat_widget .approve_recent_auto_review_denial(thread_id, id); @@ -1256,7 +1274,8 @@ impl App { AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); let profile = self.active_profile.as_deref(); - self.config.service_tier = service_tier; + self.config.service_tier = + service_tier.map(|service_tier| service_tier.request_value().to_string()); let mut edits = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_service_tier(service_tier); @@ -1696,6 +1715,9 @@ impl App { AppEvent::SetHookEnabled { key, enabled } => { self.set_hook_enabled(app_server, key, enabled); } + AppEvent::TrustHook { key, current_hash } => { + self.trust_hook(app_server, key, current_hash); + } AppEvent::HookEnabledSet { key, enabled, @@ -1720,6 +1742,11 @@ impl App { } } } + AppEvent::HookTrusted { result } => { + if let Err(err) = result { + self.chat_widget.add_error_message(err); + } + } AppEvent::OpenPermissionsPopup => { self.chat_widget.open_permissions_popup(); } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index f223db9bb3..905f62f86f 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -69,6 +69,25 @@ impl App { tui.frame_requester().schedule_frame(); } + pub(super) fn apply_raw_output_mode( + &mut self, + tui: &mut tui::Tui, + enabled: bool, + notify: bool, + ) { + if notify { + self.chat_widget.set_raw_output_mode_and_notify(enabled); + } else { + self.chat_widget.set_raw_output_mode(enabled); + } + if let Err(err) = self.reflow_transcript_now(tui) { + tracing::warn!(error = %err, "failed to reflow transcript after raw output mode toggle"); + self.chat_widget + .add_error_message(format!("Failed to redraw transcript: {err}")); + } + tui.frame_requester().schedule_frame(); + } + pub(super) async fn handle_key_event( &mut self, tui: &mut tui::Tui, @@ -137,6 +156,13 @@ impl App { return; } + if app_keymap_shortcuts_available && self.keymap.app.toggle_raw_output.is_pressed(key_event) + { + let enabled = !self.chat_widget.raw_output_mode(); + self.apply_raw_output_mode(tui, enabled, /*notify*/ false); + return; + } + if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index c98a54180c..0ab8e14ee3 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -118,6 +118,7 @@ mod tests { fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -129,6 +130,7 @@ mod tests { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index cfcbd7ce9c..671e41461c 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -665,6 +665,7 @@ mod tests { thread_id: "thread-1".to_string(), turn: Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, error: None, diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 58b2e21dff..7775aed71b 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -26,6 +26,7 @@ use super::App; use super::InitialHistoryReplayBuffer; use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::insert_history::HistoryLineWrapPolicy; use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; use crate::tui; @@ -75,7 +76,8 @@ impl App { cell: &dyn HistoryCell, width: u16, ) -> Vec> { - let mut display = cell.display_lines(width); + let mut display = + cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); if !display.is_empty() && !cell.is_stream_continuation() { if self.has_emitted_history_lines { display.insert(0, Line::from("")); @@ -99,7 +101,7 @@ impl App { if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines(display); + tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); } } @@ -158,7 +160,7 @@ impl App { } let retained_lines = buffer.retained_lines.into_iter().collect::>(); - tui.insert_history_lines(retained_lines); + tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy()); } pub(super) fn insert_history_cell_lines_with_initial_replay_buffer( @@ -188,11 +190,19 @@ impl App { } else if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines(display); + tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); } } } + pub(crate) fn history_line_wrap_policy(&self) -> HistoryLineWrapPolicy { + if self.chat_widget.raw_output_mode() { + HistoryLineWrapPolicy::Terminal + } else { + HistoryLineWrapPolicy::PreWrap + } + } + /// Retain only the newest rendered rows for initial resume replay. /// /// The oldest rows are dropped first because terminal scrollback caps preserve the tail of the @@ -408,7 +418,7 @@ impl App { Ok(()) } - fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result { + pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result { let width = tui.terminal.size()?.width; if self.transcript_cells.is_empty() { // Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells. @@ -426,7 +436,10 @@ impl App { self.deferred_history_lines.clear(); if !reflowed_lines.is_empty() { - tui.insert_history_lines(reflowed_lines); + tui.insert_history_lines_with_wrap_policy( + reflowed_lines, + self.history_line_wrap_policy(), + ); } Ok(width) @@ -448,7 +461,7 @@ impl App { while start > 0 { start -= 1; let cell = self.transcript_cells[start].clone(); - let lines = cell.display_lines(width); + let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); rendered_rows += lines.len(); cell_displays.push_front(ReflowCellDisplay { lines, @@ -468,7 +481,7 @@ impl App { start -= 1; let cell = self.transcript_cells[start].clone(); cell_displays.push_front(ReflowCellDisplay { - lines: cell.display_lines(width), + lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), is_stream_continuation: cell.is_stream_continuation(), }); } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 05aba144c8..b114d08be9 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -617,7 +617,10 @@ impl App { pub(super) fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); - config.service_tier = self.chat_widget.configured_service_tier(); + config.service_tier = self + .chat_widget + .configured_service_tier() + .map(|service_tier| service_tier.request_value().to_string()); config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out(); config } diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 41972e6751..482c75b3fa 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -77,6 +77,16 @@ pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: & ))); } +pub(super) fn hooks_needing_review_warning(count: usize) -> Option { + match count { + 0 => None, + 1 => Some("1 hook needs review before it can run. Open /hooks to review it.".to_string()), + count => Some(format!( + "{count} hooks need review before they can run. Open /hooks to review them." + )), + } +} + pub(super) fn should_show_model_migration_prompt( current_model: &str, target_model: &str, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index dacfabcb72..5ab85887a7 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -37,6 +37,8 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; @@ -299,6 +301,17 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread( assert!(app.transcript_cells.is_empty()); } +#[test] +fn hooks_needing_review_startup_warning_snapshot() { + let message = startup_prompts::hooks_needing_review_warning(/*count*/ 2) + .expect("review-needed hooks should produce a startup warning"); + let rendered = lines_to_single_string( + &history_cell::new_warning_event(message).display_lines(/*width*/ 80), + ); + + assert_app_snapshot!("hooks_needing_review_startup_warning", rendered); +} + #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -537,18 +550,9 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let handled = app - .try_handle_local_history_op( - thread_id, - &Op::GetHistoryEntryRequest { - offset: 0, - log_id: 1, - }, - ) + app.lookup_message_history_entry(thread_id, /*offset*/ 0, /*log_id*/ 1) .await?; - assert!(handled); - let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) .await .expect("history lookup should emit an app event") @@ -2744,6 +2748,84 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( ); } +#[tokio::test] +async fn inactive_thread_url_elicitation_routes_to_app_link() { + let app = make_test_app().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(9), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + let Some(ThreadInteractiveRequest::AppLink(params)) = app + .interactive_request_for_thread_request(thread_id, &request) + .await + else { + panic!("expected app link request"); + }; + + assert_eq!(params.title, "Action required"); + assert_eq!(params.description, Some("Server: payments".to_string())); + assert_eq!(params.url, "https://payments.example/checkout/123"); + assert_eq!( + params.elicitation_target, + Some(crate::bottom_pane::AppLinkElicitationTarget { + thread_id, + server_name: "payments".to_string(), + request_id: AppServerRequestId::Integer(9), + }) + ); +} + +#[tokio::test] +async fn inactive_thread_invalid_url_elicitation_is_declined() { + let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(10), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + assert!( + app.interactive_request_for_thread_request(thread_id, &request) + .await + .is_none() + ); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: AppServerRequestId::Integer(10), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> { let mut app = make_test_app().await; @@ -2849,6 +2931,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + session_id: agent_thread_id.to_string(), forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, @@ -2860,6 +2943,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), git_info: None, @@ -2930,6 +3014,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + session_id: agent_thread_id.to_string(), forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, @@ -2941,6 +3026,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), git_info: None, @@ -2984,6 +3070,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { let thread = Thread { id: read_thread_id.to_string(), + session_id: read_thread_id.to_string(), forked_from_id: None, preview: "read thread".to_string(), ephemeral: false, @@ -2995,6 +3082,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { cwd: test_path_buf("/tmp/read").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -3654,8 +3742,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -3900,8 +3987,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } @@ -4078,6 +4164,7 @@ async fn height_shrink_schedules_resize_reflow() { fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items, status, error: None, @@ -4398,7 +4485,11 @@ async fn fresh_session_config_uses_current_service_tier() { assert_eq!( config.service_tier, - Some(codex_protocol::config_types::ServiceTier::Fast) + Some( + codex_protocol::config_types::ServiceTier::Fast + .request_value() + .to_string() + ) ); } @@ -4441,8 +4532,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -4505,8 +4595,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -4598,8 +4687,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -4656,6 +4744,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { turns: vec![ Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "user-1".to_string(), content: vec![AppServerUserInput::Text { @@ -4671,6 +4760,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { }, Turn { id: "turn-2".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ ThreadItem::UserMessage { id: "user-2".to_string(), @@ -4944,6 +5034,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { &ThreadRollbackResponse { thread: Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -4955,6 +5046,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -4993,8 +5085,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -5115,8 +5206,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 5b278bd2c1..56477c18cf 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -354,8 +354,7 @@ mod tests { cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } @@ -364,6 +363,7 @@ mod tests { fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items, status, error: None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index df6f01e8bd..1a7986ad31 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -262,31 +262,47 @@ impl App { }), ), ServerRequest::McpServerElicitationRequest { request_id, params } => { - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = AppLinkViewParams::from_url_app_server_request( thread_id, + ¶ms.server_name, request_id.clone(), - params.clone(), + ¶ms.request, ) { + Some(ThreadInteractiveRequest::AppLink(params)) + } else if let Some(request) = + McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id.clone(), + params.clone(), + ) + { Some(ThreadInteractiveRequest::McpServerElicitation(request)) } else { - Some(ThreadInteractiveRequest::Approval( - ApprovalRequest::McpElicitation { - thread_id, - thread_label, - server_name: params.server_name.clone(), - request_id: request_id.clone(), - message: match ¶ms.request { - codex_app_server_protocol::McpServerElicitationRequest::Form { - message, - .. - } - | codex_app_server_protocol::McpServerElicitationRequest::Url { - message, - .. - } => message.clone(), + match ¶ms.request { + codex_app_server_protocol::McpServerElicitationRequest::Form { + message, + .. + } => Some(ThreadInteractiveRequest::Approval( + ApprovalRequest::McpElicitation { + thread_id, + thread_label, + server_name: params.server_name.clone(), + request_id: request_id.clone(), + message: message.clone(), }, - }, - )) + )), + codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name.clone(), + request_id.clone(), + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + None + } + } } } ServerRequest::PermissionsRequestApproval { params, .. } => Some( @@ -304,6 +320,9 @@ impl App { pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) { match request { + ThreadInteractiveRequest::AppLink(params) => { + self.chat_widget.open_app_link_view(params); + } ThreadInteractiveRequest::Approval(request) => { self.render_inactive_patch_preview(&request); self.chat_widget.push_approval_request(request); @@ -394,10 +413,6 @@ impl App { ) -> Result<()> { crate::session_log::log_outbound_op(&op); - if self.try_handle_local_history_op(thread_id, &op).await? { - return Ok(()); - } - if self .try_resolve_app_server_request(app_server, thread_id, &op) .await? @@ -422,70 +437,57 @@ impl App { Ok(()) } - /// Spawn a background task that fetches MCP server status from the app-server - /// via paginated RPCs, then delivers the result back through - /// `AppEvent::McpInventoryLoaded`. - /// - /// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale - /// result may arrive after the user has moved on. We currently accept that - /// tradeoff because the effect is limited to stale inventory output in history, - /// while request-token invalidation would add cross-cutting async state for a - /// low-severity path. - pub(super) async fn try_handle_local_history_op( + /// Persist prompt text in the local cross-session message history. + pub(super) fn append_message_history_entry(&self, thread_id: ThreadId, text: String) { + let history_config = codex_message_history::HistoryConfig::new( + self.chat_widget.config_ref().codex_home.clone(), + &self.chat_widget.config_ref().history, + ); + tokio::spawn(async move { + if let Err(err) = + codex_message_history::append_entry(&text, thread_id, &history_config).await + { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to append to message history" + ); + } + }); + } + + /// Fetch one local cross-session message history entry for the requesting thread. + pub(super) async fn lookup_message_history_entry( &mut self, thread_id: ThreadId, - op: &AppCommand, - ) -> Result { - match op { - AppCommand::AddToHistory { text } => { - let text = text.to_string(); - let config = self.chat_widget.config_ref().clone(); - tokio::spawn(async move { - if let Err(err) = append_message_history_entry(&text, &thread_id, &config).await - { - tracing::warn!( - thread_id = %thread_id, - error = %err, - "failed to append to message history" - ); - } - }); - Ok(true) - } - AppCommand::GetHistoryEntryRequest { offset, log_id } => { - let config = self.chat_widget.config_ref().clone(); - let app_event_tx = self.app_event_tx.clone(); - let offset = *offset; - let log_id = *log_id; - tokio::spawn(async move { - let entry_opt = tokio::task::spawn_blocking(move || { - lookup_message_history_entry(log_id, offset, &config) - }) - .await - .unwrap_or_else(|err| { - tracing::warn!(error = %err, "history lookup task failed"); - None - }); + offset: usize, + log_id: u64, + ) -> Result<()> { + let history_config = codex_message_history::HistoryConfig::new( + self.chat_widget.config_ref().codex_home.clone(), + &self.chat_widget.config_ref().history, + ); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let entry_opt = tokio::task::spawn_blocking(move || { + codex_message_history::lookup(log_id, offset, &history_config) + }) + .await + .unwrap_or_else(|err| { + tracing::warn!(error = %err, "history lookup task failed"); + None + }); - app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { - thread_id, - event: HistoryLookupResponse { - offset, - log_id, - entry: entry_opt.map(|entry| { - codex_protocol::message_history::HistoryEntry { - conversation_id: entry.session_id, - ts: entry.ts, - text: entry.text, - } - }), - }, - }); - }); - Ok(true) - } - _ => Ok(false), - } + app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { + thread_id, + event: HistoryLookupResponse { + offset, + log_id, + entry: entry_opt.map(|entry| entry.text), + }, + }); + }); + Ok(()) } pub(super) async fn try_submit_active_thread_op_via_app_server( @@ -604,7 +606,7 @@ impl App { model.to_string(), *effort, *summary, - *service_tier, + service_tier.clone(), collaboration_mode.clone(), *personality, final_output_json_schema.clone(), @@ -922,8 +924,7 @@ impl App { } else if rollout_path.is_some() { session.model.clear(); } - session.history_log_id = 0; - session.history_entry_count = 0; + session.message_history = None; session.rollout_path = rollout_path; self.upsert_agent_picker_thread( thread_id, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 3a898b82a3..524d8f3c06 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -63,7 +63,10 @@ impl App { thread_name: None, model: self.chat_widget.current_model().to_string(), model_provider_id: self.config.model_provider_id.clone(), - service_tier: self.chat_widget.current_service_tier(), + service_tier: self + .chat_widget + .current_service_tier() + .map(|service_tier| service_tier.request_value().to_string()), approval_policy: AskForApproval::from( self.config.permissions.approval_policy.value(), ), @@ -73,8 +76,7 @@ impl App { cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: thread.path.clone(), }); @@ -93,8 +95,7 @@ impl App { } else if thread.path.is_some() { session.model.clear(); } - session.history_log_id = 0; - session.history_entry_count = 0; + session.message_history = None; session } @@ -150,8 +151,7 @@ mod tests { cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } @@ -322,6 +322,7 @@ mod tests { }; let read_thread = Thread { id: read_thread_id.to_string(), + session_id: read_thread_id.to_string(), forked_from_id: None, preview: "read thread".to_string(), ephemeral: false, @@ -333,6 +334,7 @@ mod tests { cwd: test_path_buf("/tmp/read").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 231e5e9cb0..307fb8559a 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -245,7 +245,7 @@ impl App { let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { let lines = std::mem::take(&mut self.deferred_history_lines); - tui.insert_history_lines(lines); + tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy()); } self.overlay = None; self.backtrack.overlay_preview_active = false; @@ -261,7 +261,10 @@ impl App { if !self.transcript_cells.is_empty() { let width = tui.terminal.last_known_screen_size.width; for cell in &self.transcript_cells { - tui.insert_history_lines(cell.display_lines(width)); + tui.insert_history_lines_with_wrap_policy( + cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + self.history_line_wrap_policy(), + ); } } } diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 8633da04bf..89fd2600f8 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -15,7 +15,6 @@ use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -46,7 +45,7 @@ pub(crate) enum AppCommand { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, final_output_json_schema: Option, collaboration_mode: Option, personality: Option, @@ -60,7 +59,7 @@ pub(crate) enum AppCommand { model: Option, effort: Option>, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, }, @@ -104,13 +103,6 @@ pub(crate) enum AppCommand { Review { target: ReviewTarget, }, - AddToHistory { - text: String, - }, - GetHistoryEntryRequest { - offset: usize, - log_id: u64, - }, ApproveGuardianDeniedAction { event: GuardianAssessmentEvent, }, @@ -154,7 +146,7 @@ impl AppCommand { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, final_output_json_schema: Option, collaboration_mode: Option, personality: Option, @@ -185,7 +177,7 @@ impl AppCommand { model: Option, effort: Option>, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, ) -> Self { @@ -276,14 +268,6 @@ impl AppCommand { Self::Review { target } } - pub(crate) fn add_to_history(text: String) -> Self { - Self::AddToHistory { text } - } - - pub(crate) fn history_lookup(offset: usize, log_id: u64) -> Self { - Self::GetHistoryEntryRequest { offset, log_id } - } - pub(crate) fn approve_guardian_denied_action(event: GuardianAssessmentEvent) -> Self { Self::ApproveGuardianDeniedAction { event } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b9e4380003..4ee405f495 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -28,7 +28,6 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; use codex_protocol::ThreadId; -use codex_protocol::message_history::HistoryEntry; use codex_protocol::openai_models::ModelPreset; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; @@ -68,7 +67,7 @@ pub(crate) enum ThreadGoalSetMode { pub(crate) struct HistoryLookupResponse { pub(crate) offset: usize, pub(crate) log_id: u64, - pub(crate) entry: Option, + pub(crate) entry: Option, } impl RealtimeAudioDeviceKind { @@ -150,6 +149,19 @@ pub(crate) enum AppEvent { event: HistoryLookupResponse, }, + /// Persist a submitted prompt in the cross-session message history. + AppendMessageHistoryEntry { + thread_id: ThreadId, + text: String, + }, + + /// Fetch a persistent cross-session message history entry by offset. + LookupMessageHistoryEntry { + thread_id: ThreadId, + offset: usize, + log_id: u64, + }, + /// Start a new session. NewSession, @@ -157,6 +169,11 @@ pub(crate) enum AppEvent { /// previous chat resumable. ClearUi, + /// Re-render the transcript using the selected scrollback rendering mode. + RawOutputModeChanged { + enabled: bool, + }, + /// Clear the current context, start a fresh session, and submit an initial user message. /// /// This is the Plan Mode handoff path: the previous thread remains resumable, but the model @@ -753,6 +770,12 @@ pub(crate) enum AppEvent { enabled: bool, }, + /// Trust the current definition for a hook by stable hook key. + TrustHook { + key: String, + current_hash: String, + }, + /// Result of persisting hook enabled state. HookEnabledSet { key: String, @@ -760,6 +783,11 @@ pub(crate) enum AppEvent { result: Result<(), String>, }, + /// Result of persisting hook trust state. + HookTrusted { + result: Result<(), String>, + }, + /// Notify that the manage skills popup was closed. ManageSkillsClosed, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 449da8e212..9f1e0f8101 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -4,11 +4,9 @@ //! request/response plumbing out of `App` and `ChatWidget`. use crate::bottom_pane::FeedbackAudience; -#[cfg(test)] -use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; -use crate::legacy_core::message_history_metadata; use crate::permission_compat::legacy_compatible_permission_profile; +use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; @@ -89,6 +87,7 @@ use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartSource; @@ -111,6 +110,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_utils_absolute_path::AbsolutePathBuf; @@ -530,7 +530,7 @@ impl AppServerSession { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, output_schema: Option, @@ -1046,6 +1046,15 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { .collect(), supports_personality: model.supports_personality, additional_speed_tiers: model.additional_speed_tiers, + service_tiers: model + .service_tiers + .into_iter() + .map(|service_tier| ModelServiceTier { + id: service_tier.id, + name: service_tier.name, + description: service_tier.description, + }) + .collect(), is_default: model.is_default, upgrade, show_in_picker: !model.hidden, @@ -1187,6 +1196,7 @@ fn thread_start_params_from_config( config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), session_start_source, + thread_source: Some(ThreadSource::User), persist_extended_history: false, ..ThreadStartParams::default() } @@ -1252,6 +1262,7 @@ fn thread_fork_params_from_config( base_instructions: config.base_instructions.clone(), developer_instructions: config.developer_instructions.clone(), ephemeral: config.ephemeral, + thread_source: Some(ThreadSource::User), persist_extended_history: false, ..ThreadForkParams::default() } @@ -1334,7 +1345,7 @@ async fn thread_session_state_from_thread_start_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1366,7 +1377,7 @@ async fn thread_session_state_from_thread_resume_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1398,7 +1409,7 @@ async fn thread_session_state_from_thread_fork_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1440,7 +1451,7 @@ async fn thread_session_state_from_thread_response( rollout_path: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, @@ -1457,8 +1468,9 @@ async fn thread_session_state_from_thread_response( .map(ThreadId::from_string) .transpose() .map_err(|err| format!("forked_from_id is invalid: {err}"))?; - let (history_log_id, history_entry_count) = message_history_metadata(config).await; - let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX); + let history_config = + codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); + let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; Ok(ThreadSessionState { thread_id, forked_from_id, @@ -1474,8 +1486,10 @@ async fn thread_session_state_from_thread_response( cwd, instruction_source_paths, reasoning_effort, - history_log_id, - history_entry_count, + message_history: Some(MessageHistoryMetadata { + log_id, + entry_count, + }), network_proxy: None, rollout_path, }) @@ -1550,6 +1564,7 @@ mod tests { .map(permissions_selection_from_active_profile) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); + assert_eq!(params.thread_source, Some(ThreadSource::User)); } #[tokio::test] @@ -1666,6 +1681,8 @@ mod tests { assert_eq!(start.permissions, None); assert_eq!(resume.permissions, None); assert_eq!(fork.permissions, None); + assert_eq!(start.thread_source, Some(ThreadSource::User)); + assert_eq!(fork.thread_source, Some(ThreadSource::User)); } #[test] @@ -1771,6 +1788,8 @@ mod tests { assert_eq!(start.permissions, None); assert_eq!(resume.permissions, None); assert_eq!(fork.permissions, None); + assert_eq!(start.thread_source, Some(ThreadSource::User)); + assert_eq!(fork.thread_source, Some(ThreadSource::User)); } #[tokio::test] @@ -1805,6 +1824,7 @@ mod tests { let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), + session_id: ThreadId::new().to_string(), forked_from_id: Some(forked_from_id.to_string()), preview: "hello".to_string(), ephemeral: false, @@ -1816,12 +1836,14 @@ mod tests { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, name: None, turns: vec![Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ codex_app_server_protocol::ThreadItem::UserMessage { id: "user-1".to_string(), @@ -1947,10 +1969,13 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); - append_message_history_entry("older", &thread_id, &config) + let history_config = + codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); + + codex_message_history::append_entry("older", &thread_id, &history_config) .await .expect("history append should succeed"); - append_message_history_entry("newer", &thread_id, &config) + codex_message_history::append_entry("newer", &thread_id, &history_config) .await .expect("history append should succeed"); @@ -1974,8 +1999,11 @@ mod tests { .await .expect("session should map"); - assert_ne!(session.history_log_id, 0); - assert_eq!(session.history_entry_count, 2); + let metadata = session + .message_history + .expect("session should include message-history metadata"); + assert_ne!(metadata.log_id, 0); + assert_eq!(metadata.entry_count, 2); } #[tokio::test] diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 43ff94618d..3702849fce 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -17,6 +17,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use textwrap::wrap; +use url::Url; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -34,6 +35,13 @@ use crate::style::user_message_style; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; +const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps"; +const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AppLinkScreen { Link, @@ -44,6 +52,8 @@ enum AppLinkScreen { pub(crate) enum AppLinkSuggestionType { Install, Enable, + Auth, + ExternalAction, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -53,6 +63,7 @@ pub(crate) struct AppLinkElicitationTarget { pub(crate) request_id: AppServerRequestId, } +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AppLinkViewParams { pub(crate) app_id: String, pub(crate) title: String, @@ -66,6 +77,152 @@ pub(crate) struct AppLinkViewParams { pub(crate) elicitation_target: Option, } +impl AppLinkViewParams { + pub(crate) fn from_url_app_server_request( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + request: &codex_app_server_protocol::McpServerElicitationRequest, + ) -> Option { + let codex_app_server_protocol::McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } = request + else { + return None; + }; + if server_name == MCP_CODEX_APPS_SERVER_NAME { + let url = validate_external_url(url, /*require_chatgpt_host*/ true)?; + return Self::from_codex_apps_auth_url_parts( + thread_id, + server_name, + request_id, + meta.as_ref(), + message, + url.as_str(), + elicitation_id, + ); + } + + let url = validate_external_url(url, /*require_chatgpt_host*/ false)?; + Some(Self::from_generic_url_parts( + thread_id, + server_name, + request_id, + message, + url.as_str(), + elicitation_id, + )) + } + + fn from_codex_apps_auth_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + meta: Option<&serde_json::Value>, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Option { + let auth_failure = meta? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let app_id = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(elicitation_id) + .to_string(); + let title = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(app_id.as_str()) + .to_string(); + + Some(Self { + app_id, + title, + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + }) + } + + fn from_generic_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Self { + Self { + app_id: elicitation_id.to_string(), + title: "Action required".to_string(), + description: Some(format!("Server: {server_name}")), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + } + } +} + +fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return None; + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return None; + } + if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) { + return None; + } + Some(parsed) +} + +fn is_allowed_chatgpt_auth_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + pub(crate) struct AppLinkView { app_id: String, title: String, @@ -116,6 +273,19 @@ impl AppLinkView { } fn action_labels(&self) -> Vec<&'static str> { + if self.is_auth_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open sign-in URL", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"], + }; + } + if self.is_external_action_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open link", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"], + }; + } + match self.screen { AppLinkScreen::Link => { if self.is_installed { @@ -148,6 +318,19 @@ impl AppLinkView { self.elicitation_target.is_some() } + fn is_auth_suggestion(&self) -> bool { + self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth) + } + + fn is_external_action_suggestion(&self) -> bool { + self.is_tool_suggestion() + && self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction) + } + + fn is_browser_action_suggestion(&self) -> bool { + self.is_auth_suggestion() || self.is_external_action_suggestion() + } + fn resolve_elicitation(&self, decision: McpServerElicitationAction) { let Some(target) = self.elicitation_target.as_ref() else { return; @@ -167,20 +350,26 @@ impl AppLinkView { self.complete = true; } - fn open_chatgpt_link(&mut self) { + fn open_external_url(&mut self) { self.app_event_tx.send(AppEvent::OpenUrlInBrowser { url: self.url.clone(), }); - if !self.is_installed { + if !self.is_installed || self.is_browser_action_suggestion() { self.screen = AppLinkScreen::InstallConfirmation; self.selected_action = 0; } } - fn refresh_connectors_and_close(&mut self) { - self.app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); + fn complete_external_flow_and_close(&mut self) { + let should_refresh_connectors = self + .elicitation_target + .as_ref() + .is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + if should_refresh_connectors { + self.app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } if self.is_tool_suggestion() { self.resolve_elicitation(McpServerElicitationAction::Accept); } @@ -209,22 +398,42 @@ impl AppLinkView { match self.suggestion_type { Some(AppLinkSuggestionType::Enable) => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Auth) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::ExternalAction) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, Some(AppLinkSuggestionType::Install) | None => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, @@ -234,12 +443,12 @@ impl AppLinkView { match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.complete = true, }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.back_to_link_screen(), }, } @@ -280,31 +489,42 @@ impl AppLinkView { } lines.push(Line::from("")); } - if self.is_installed { + let is_browser_action_suggestion = self.is_browser_action_suggestion(); + if self.is_installed && !is_browser_action_suggestion { for line in wrap("Use $ to insert this app into the prompt.", usable_width) { lines.push(Line::from(line.into_owned())); } lines.push(Line::from("")); } + if is_browser_action_suggestion { + lines.push(Line::from("URL".dim())); + for line in wrap(&self.url, usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + let instructions = self.instructions.trim(); if !instructions.is_empty() { for line in wrap(instructions, usable_width) { lines.push(Line::from(line.into_owned())); } - for line in wrap( - "Newly installed apps can take a few minutes to appear in /apps.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - if !self.is_installed { + if !is_browser_action_suggestion { for line in wrap( - "After installed, use $ to insert this app into the prompt.", + "Newly installed apps can take a few minutes to appear in /apps.", usable_width, ) { lines.push(Line::from(line.into_owned())); } + if !self.is_installed { + for line in wrap( + "After installed, use $ to insert this app into the prompt.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } } lines.push(Line::from("")); } @@ -316,24 +536,82 @@ impl AppLinkView { let usable_width = width.max(1) as usize; let mut lines: Vec> = Vec::new(); - lines.push(Line::from("Finish App Setup".bold())); + let is_auth_suggestion = self.is_auth_suggestion(); + let is_external_action_suggestion = self.is_external_action_suggestion(); + let is_codex_apps_auth = is_auth_suggestion + && self + .elicitation_target + .as_ref() + .is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + lines.push(Line::from( + if is_auth_suggestion { + if is_codex_apps_auth { + "Finish App Sign In" + } else { + "Finish Authentication" + } + } else if is_external_action_suggestion { + "Finish in Browser" + } else { + "Finish App Setup" + } + .bold(), + )); lines.push(Line::from("")); - for line in wrap( - "Complete app setup on ChatGPT in the browser window that just opened.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - for line in wrap( - "Sign in there if needed, then return here and select \"I already Installed it\".", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); + if is_auth_suggestion { + for line in wrap( + if is_codex_apps_auth { + "Sign in to the app on ChatGPT in the browser window that just opened." + } else { + "Complete authentication in the browser window that just opened." + }, + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Then return here and select \"I already signed in\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } else if is_external_action_suggestion { + for line in wrap( + "Complete the requested action in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap("Then return here and select \"I finished\".", usable_width) { + lines.push(Line::from(line.into_owned())); + } + } else { + for line in wrap( + "Complete app setup on ChatGPT in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Sign in there if needed, then return here and select \"I already Installed it\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } } lines.push(Line::from("")); - lines.push(Line::from(vec!["Setup URL:".dim()])); + lines.push(Line::from(vec![ + if is_auth_suggestion { + "Sign-in URL:" + } else if is_external_action_suggestion { + "Link:" + } else { + "Setup URL:" + } + .dim(), + ])); let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); lines.extend(adaptive_wrap_lines( vec![url_line], @@ -586,6 +864,135 @@ mod tests { } } + fn generic_url_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid thread id"), + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + } + } + + fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest { + codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: Some(serde_json::json!({ + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "connector_id": "connector_calendar", + "connector_name": "Google Calendar", + }, + }, + })), + message: "Reconnect Google Calendar on ChatGPT.".to_string(), + url: url.to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + } + + #[test] + fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() { + let target = suggestion_target(); + let request = + auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar"); + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected auth app link params"); + + assert_eq!(params.app_id, "connector_calendar"); + assert_eq!(params.title, "Google Calendar"); + assert_eq!( + params.url, + "https://chatgpt.com/apps/google-calendar/connector_calendar" + ); + assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth)); + assert_eq!(params.elicitation_target, Some(target)); + } + + #[test] + fn non_codex_apps_url_elicitation_builds_generic_app_link_params() { + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + + assert_eq!( + params, + AppLinkViewParams { + app_id: "payment-123".to_string(), + title: "Action required".to_string(), + description: Some("Server: payments".to_string()), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: "https://payments.example/checkout/123".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Review the payment details to continue.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(target), + } + ); + } + + #[test] + fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() { + let target = suggestion_target(); + for url in [ + "http://chatgpt.com/apps/google-calendar/connector_calendar", + "https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar", + "https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar", + "https://evilchatgpt.com/apps/google-calendar/connector_calendar", + ] { + let request = auth_url_request(url); + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + + #[test] + fn generic_url_elicitation_rejects_untrusted_urls() { + let target = generic_url_target(); + for url in [ + "http://payments.example/checkout/123", + "https://user:pass@payments.example/checkout/123", + ] { + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: url.to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + fn render_snapshot(view: &AppLinkView, area: Rect) -> String { let mut buf = Buffer::empty(area); view.render(area, &mut buf); @@ -717,6 +1124,58 @@ mod tests { ); } + #[test] + fn generic_url_elicitation_resolves_without_connector_refresh() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://payments.example/checkout/123"); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, target.thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + decision: McpServerElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(rx.try_recv().is_err()); + assert!(view.is_complete()); + } + #[test] fn install_confirmation_does_not_split_long_url_like_token_without_scheme() { let (tx_raw, _rx) = unbounded_channel::(); @@ -1076,4 +1535,94 @@ mod tests { ) ); } + + #[test] + fn auth_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar" + .to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_auth_suggestion_with_reason", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let view = AppLinkView::new(params, tx); + + assert_snapshot!( + "app_link_view_generic_url_elicitation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_confirmation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_snapshot!( + "app_link_view_generic_url_elicitation_confirmation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4433d79cf4..e7526203bc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -59,6 +59,17 @@ //! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so //! pasted content and text elements are preserved when extracting args. //! +//! # Large Paste Placeholders +//! +//! Large pastes insert an element placeholder in the buffer and store the full text in +//! `pending_pastes`. The placeholder label is derived from the pasted character count: +//! +//! - First paste of a given size uses `[Pasted Content N chars]`. +//! - Additional pending pastes of the same size add a numeric suffix (`#2`, `#3`, ...), where the +//! next suffix is computed from the placeholders that still exist in `pending_pastes`. +//! - When all placeholders for a size are cleared or deleted, the next paste of that size reuses +//! the base label without a suffix. +//! //! # Remote Image Rows (Up/Down/Delete) //! //! Remote image URLs are rendered as non-editable `[Image #N]` rows above the textarea (inside the @@ -196,6 +207,7 @@ use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::style::user_message_style; +use codex_protocol::ThreadId; use codex_protocol::models::local_image_label_text; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -338,7 +350,6 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, - large_paste_counters: HashMap, has_focus: bool, frame_requester: Option, /// Invariant: attached images are labeled in vec order as @@ -536,7 +547,6 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), - large_paste_counters: HashMap::new(), has_focus: has_input_focus, frame_requester: None, attached_images: Vec::new(), @@ -840,10 +850,15 @@ impl ChatComposer { && self.remote_image_urls.is_empty() } - /// Record the history metadata advertised by `SessionConfiguredEvent` so - /// that the composer can navigate cross-session history. - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history.set_metadata(log_id, entry_count); + /// Record local persistent-history metadata so the composer can navigate + /// cross-session history. + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.history.set_metadata(thread_id, log_id, entry_count); } /// Integrate an asynchronous response to an on-demand history lookup. @@ -1120,12 +1135,13 @@ impl ChatComposer { } pub(crate) fn current_text_with_pending(&self) -> String { - let mut text = self.current_text(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } + let text = self.current_text(); + if self.pending_pastes.is_empty() { + return text; } + + let (text, _) = + Self::expand_pending_pastes(&text, self.current_text_elements(), &self.pending_pastes); text } @@ -1625,14 +1641,27 @@ impl ChatComposer { .is_some_and(|expires_at| Instant::now() < expires_at) } - fn next_large_paste_placeholder(&mut self, char_count: usize) -> String { + fn next_large_paste_placeholder(&self, char_count: usize) -> String { let base = format!("[Pasted Content {char_count} chars]"); - let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0); - *next_suffix += 1; - if *next_suffix == 1 { + let prefix = format!("{base} #"); + let mut max_suffix = 0usize; + + for (placeholder, _) in &self.pending_pastes { + if placeholder == &base { + max_suffix = max_suffix.max(1); + continue; + } + if let Some(suffix) = placeholder.strip_prefix(&prefix) + && let Ok(value) = suffix.parse::() + { + max_suffix = max_suffix.max(value); + } + } + + if max_suffix == 0 { base } else { - format!("{base} #{next_suffix}") + format!("{base} #{}", max_suffix + 1) } } @@ -2810,24 +2839,27 @@ impl ChatComposer { if !self.slash_commands_enabled() || self.is_bash_mode { return None; } - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) - && rest.is_empty() - && let Some(cmd) = - slash_commands::find_builtin_command(name, self.builtin_command_flags()) - { - if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(); - self.record_pending_slash_command_history(); - return Some(InputResult::None); - } - self.stage_slash_command_history(); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; - Some(InputResult::Command(cmd)) - } else { - None + let text = self.textarea.text(); + let first_line = text.lines().next().unwrap_or(""); + let (name, rest, _rest_offset) = parse_slash_name(first_line)?; + if !rest.is_empty() { + return None; } + let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; + if cmd.supports_inline_args() + && parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty()) + { + return None; + } + if self.reject_slash_command_if_unavailable(cmd) { + self.stage_slash_command_history(cmd); + self.record_pending_slash_command_history(); + return Some(InputResult::None); + } + self.stage_slash_command_history(cmd); + self.textarea.set_text_clearing_elements(""); + self.is_bash_mode = false; + Some(InputResult::Command(cmd)) } /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. @@ -2852,12 +2884,12 @@ impl ChatComposer { return None; } if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); self.record_pending_slash_command_history(); return Some(InputResult::None); } - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); @@ -2933,7 +2965,10 @@ impl ChatComposer { /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` /// commits the staged entry after dispatch so command recall follows the submitted text, not /// the command outcome. - fn stage_slash_command_history(&mut self) { + fn stage_slash_command_history(&mut self, cmd: SlashCommand) { + if cmd == SlashCommand::Clear { + return; + } self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); } @@ -2942,6 +2977,9 @@ impl ChatComposer { /// Popup filtering text can be partial, so recording the selected command avoids recalling /// `/di` after the user actually accepted `/diff`. fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) { + if cmd == SlashCommand::Clear { + return; + } self.stage_slash_command_history_text(format!("/{}", cmd.command())); } @@ -5718,6 +5756,35 @@ mod tests { } } + #[test] + fn large_paste_numbering_reuses_after_ctrl_c_clear() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + + composer.handle_paste(paste.clone()); + assert_eq!(composer.textarea.text(), base); + assert_eq!(composer.pending_pastes.len(), 1); + + assert_eq!(composer.clear_for_ctrl_c(), Some(base.clone())); + assert!(composer.textarea.text().is_empty()); + assert!(composer.pending_pastes.is_empty()); + + composer.handle_paste(paste); + assert_eq!(composer.textarea.text(), base); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, base); + } + #[test] fn vim_mode_resets_to_normal_after_submission() { use crossterm::event::KeyCode; @@ -6258,6 +6325,7 @@ mod tests { policy: None, path_to_skills_md: skill_path.clone(), scope: crate::test_support::skill_scope_user(), + plugin_id: None, }])); let ActivePopup::Skill(popup) = &composer.active_popup else { @@ -6300,6 +6368,7 @@ mod tests { policy: None, path_to_skills_md: skill_path.clone(), scope: crate::test_support::skill_scope_repo(), + plugin_id: None, }])); composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { config_name: "google-calendar@debug".to_string(), @@ -6391,6 +6460,7 @@ mod tests { policy: None, path_to_skills_md: test_path_buf("/tmp/repo/google-calendar/SKILL.md").abs(), scope: crate::test_support::skill_scope_repo(), + plugin_id: None, }])); composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { config_name: "google-calendar@debug".to_string(), @@ -8533,10 +8603,10 @@ mod tests { assert_eq!(composer.pending_pastes[0].1, paste); } - /// Behavior: large-paste placeholder numbering does not get reused after deletion, so a new - /// paste of the same length gets a new unique placeholder label. + /// Behavior: large-paste placeholder numbering continues when another placeholder of the + /// same length still exists, so a new paste gets a new unique placeholder label. #[test] - fn large_paste_numbering_does_not_reuse_after_deletion() { + fn large_paste_numbering_continues_with_same_length_placeholder() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -8575,6 +8645,42 @@ mod tests { assert_eq!(composer.pending_pastes[1].0, third); } + /// Behavior: if all placeholders of a given length are removed, numbering resets to the + /// base placeholder on the next paste. + #[test] + fn large_paste_numbering_reuses_after_all_deleted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + + composer.handle_paste(paste.clone()); + assert_eq!(composer.textarea.text(), base); + assert_eq!(composer.pending_pastes.len(), 1); + + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(composer.textarea.text().is_empty()); + assert!(composer.pending_pastes.is_empty()); + + composer.handle_paste(paste); + assert_eq!(composer.textarea.text(), base); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, base); + } + #[test] fn test_partial_placeholder_deletion() { use crossterm::event::KeyCode; @@ -10132,6 +10238,33 @@ mod tests { ); } + #[test] + fn current_text_with_pending_expands_overlapping_placeholders() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let first_paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let second_paste = "b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", first_paste.chars().count()); + let second = format!("{base} #2"); + + composer.handle_paste(first_paste.clone()); + composer.handle_paste(second_paste.clone()); + + assert_eq!(composer.current_text(), format!("{base}{second}")); + assert_eq!( + composer.current_text_with_pending(), + format!("{first_paste}{second_paste}") + ); + } + #[test] fn apply_external_edit_limits_duplicates_to_occurrences() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 52ae811226..6a490e81ec 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -15,11 +15,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; -use crate::app_command::AppCommand as Op; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::MentionBinding; use crate::mention_codec::decode_history_mentions; +use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; /// A composer history entry that can rehydrate draft state. @@ -105,11 +105,13 @@ impl HistoryEntry { /// the chat composer. This struct is intentionally decoupled from the /// rendering widget so the logic remains isolated and easier to test. pub(crate) struct ChatComposerHistory { - /// Identifier of the history log as reported by `SessionConfiguredEvent`. - history_log_id: Option, + /// Thread that owns persistent lookup responses for this metadata snapshot. + thread_id: Option, + /// Identifier of the persistent history log used for stale lookup rejection. + persistent_log_id: Option, /// Number of entries already present in the persistent cross-session /// history file when the session started. - history_entry_count: usize, + persistent_entry_count: usize, /// Messages submitted by the user *during this UI session* (newest at END). /// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs). @@ -216,8 +218,9 @@ impl ChatComposerHistory { /// metadata-free lets the composer reset and reuse this helper across session lifecycles. pub fn new() -> Self { Self { - history_log_id: None, - history_entry_count: 0, + thread_id: None, + persistent_log_id: None, + persistent_entry_count: 0, local_history: Vec::new(), fetched_history: HashMap::new(), history_cursor: None, @@ -231,9 +234,10 @@ impl ChatComposerHistory { /// This clears fetched entries, local entries, navigation cursors, and active search state /// because offsets only make sense within one history log snapshot. Reusing old offsets after a /// log-id change would allow a stale async response to hydrate the wrong prompt. - pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history_log_id = Some(log_id); - self.history_entry_count = entry_count; + pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) { + self.thread_id = Some(thread_id); + self.persistent_log_id = Some(log_id); + self.persistent_entry_count = entry_count; self.fetched_history.clear(); self.local_history.clear(); self.history_cursor = None; @@ -298,7 +302,7 @@ impl ChatComposerHistory { /// history recall. If callers moved the cursor into the middle of a recalled entry and still /// forced navigation, users would lose normal vertical movement within the draft. pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { - if self.history_entry_count == 0 && self.local_history.is_empty() { + if self.persistent_entry_count == 0 && self.local_history.is_empty() { return false; } @@ -320,11 +324,11 @@ impl ChatComposerHistory { /// Handles Up by moving toward older entries in the combined history space. /// /// Local entries can be returned immediately, while missing persistent entries emit a - /// `GetHistoryEntryRequest` and return `None` until the response arrives. Calling this while + /// `LookupMessageHistoryEntry` and return `None` until the response arrives. Calling this while /// Ctrl+R search is active intentionally exits search traversal. pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; - let total_entries = self.history_entry_count + self.local_history.len(); + let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } @@ -346,7 +350,7 @@ impl ChatComposerHistory { /// search state and resumes normal shell-style browsing. pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; - let total_entries = self.history_entry_count + self.local_history.len(); + let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } @@ -385,7 +389,7 @@ impl ChatComposerHistory { entry: Option, app_event_tx: &AppEventSender, ) -> HistoryEntryResponse { - if self.history_log_id != Some(log_id) { + if self.persistent_log_id != Some(log_id) { return HistoryEntryResponse::Ignored; } @@ -517,7 +521,7 @@ impl ChatComposerHistory { // --------------------------------------------------------------------- fn total_entries(&self) -> usize { - self.history_entry_count + self.local_history.len() + self.persistent_entry_count + self.local_history.len() } fn search_start_offset( @@ -588,8 +592,8 @@ impl ChatComposerHistory { if self.search_matches(&entry) && self.search_result_is_unique(&entry) { return self.search_match(offset, entry); } - } else if offset < self.history_entry_count - && let Some(log_id) = self.history_log_id + } else if offset < self.persistent_entry_count + && let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { if let Some(search) = self.search.as_mut() { search.awaiting = Some(PendingHistorySearch { @@ -598,7 +602,11 @@ impl ChatComposerHistory { boundary_if_exhausted, }); } - app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id))); + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset, + log_id, + }); return HistorySearchResult::Pending; } @@ -618,9 +626,9 @@ impl ChatComposerHistory { } fn entry_at_cached_offset(&self, offset: usize) -> Option { - if offset >= self.history_entry_count { + if offset >= self.persistent_entry_count { self.local_history - .get(offset - self.history_entry_count) + .get(offset - self.persistent_entry_count) .cloned() } else { self.fetched_history.get(&offset).cloned() @@ -702,11 +710,11 @@ impl ChatComposerHistory { global_idx: usize, app_event_tx: &AppEventSender, ) -> Option { - if global_idx >= self.history_entry_count { + if global_idx >= self.persistent_entry_count { // Local entry. if let Some(entry) = self .local_history - .get(global_idx - self.history_entry_count) + .get(global_idx - self.persistent_entry_count) .cloned() { self.last_history_text = Some(entry.text.clone()); @@ -715,8 +723,12 @@ impl ChatComposerHistory { } else if let Some(entry) = self.fetched_history.get(&global_idx).cloned() { self.last_history_text = Some(entry.text.clone()); return Some(entry); - } else if let Some(log_id) = self.history_log_id { - app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(global_idx, log_id))); + } else if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset: global_idx, + log_id, + }); } None } @@ -794,6 +806,11 @@ mod tests { use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; + fn test_thread_id() -> ThreadId { + ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse") + } + #[test] fn duplicate_submissions_are_not_recorded() { let mut history = ChatComposerHistory::new(); @@ -830,7 +847,8 @@ mod tests { let mut history = ChatComposerHistory::new(); // Pretend there are 3 persistent entries. - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); // First Up should request offset 2 (latest) and await async data. assert!(history.should_handle_navigation("", /*cursor*/ 0)); @@ -838,16 +856,17 @@ mod tests { // Verify that a history lookup request was sent. let event = rx.try_recv().expect("expected AppEvent to be sent"); - let AppEvent::CodexOp(op) = event else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); // Inject the async response. assert_eq!( @@ -865,16 +884,17 @@ mod tests { // Verify second lookup request for offset 1. let event2 = rx.try_recv().expect("expected second event"); - let AppEvent::CodexOp(op) = event2 else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event2 + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 1); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Found(HistoryEntry::new("older".to_string())), @@ -1017,7 +1037,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, @@ -1090,7 +1110,8 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, @@ -1101,16 +1122,17 @@ mod tests { &tx ) ); - let AppEvent::CodexOp(op) = rx.try_recv().expect("expected latest lookup") else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = rx.try_recv().expect("expected latest lookup") + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Pending), @@ -1121,16 +1143,17 @@ mod tests { &tx ) ); - let AppEvent::CodexOp(op) = rx.try_recv().expect("expected next lookup") else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = rx.try_recv().expect("expected next lookup") + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 1); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( @@ -1151,7 +1174,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 4); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 4); assert_eq!( HistorySearchResult::Pending, @@ -1270,7 +1293,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); history .fetched_history .insert(1, HistoryEntry::new("command2".to_string())); diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 2f4c6a8a0d..c146bae8b4 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -2,6 +2,7 @@ use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HookTrustStatus; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -67,7 +68,12 @@ impl HooksBrowserView { app_event_tx, }; if view.page_len() > 0 { - view.state.selected_idx = Some(0); + view.state.selected_idx = Some( + view.event_rows() + .iter() + .position(|row| row.needs_review > 0) + .unwrap_or(0), + ); } view } @@ -84,14 +90,18 @@ impl HooksBrowserView { let active = self .hooks .iter() - .filter(|hook| { - hook.event_name == event_name && (hook.enabled || hook.is_managed) - }) + .filter(|hook| hook.event_name == event_name && hook_is_active(hook)) + .count(); + let needs_review = self + .hooks + .iter() + .filter(|hook| hook.event_name == event_name && hook_needs_review(hook)) .count(); EventRow { event_name, installed, active, + needs_review, } }) .collect() @@ -169,6 +179,9 @@ impl HooksBrowserView { if hook.is_managed { return; } + if hook_needs_review(hook) { + return; + } hook.enabled = !hook.enabled; self.app_event_tx.send(AppEvent::SetHookEnabled { @@ -177,6 +190,24 @@ impl HooksBrowserView { }); } + fn trust_selected_hook(&mut self, event_name: HookEventName) { + let Some(idx) = self.selected_hook_index(event_name) else { + return; + }; + let Some(hook) = self.hooks.get_mut(idx) else { + return; + }; + if !hook_needs_review(hook) { + return; + } + + hook.trust_status = HookTrustStatus::Trusted; + self.app_event_tx.send(AppEvent::TrustHook { + key: hook.key.clone(), + current_hash: hook.current_hash.clone(), + }); + } + fn close(&mut self) { self.complete = true; } @@ -205,26 +236,50 @@ impl HooksBrowserView { ] } - fn handler_header_lines(event_name: HookEventName) -> Vec> { - vec![ - format!("{} hooks", event_label(event_name)).bold().into(), - "Turn hooks on or off. Your changes are saved automatically." - .dim() - .into(), - ] + fn handler_header_lines( + event_name: HookEventName, + review_needed_count: usize, + ) -> Vec> { + let mut lines = vec![format!("{} hooks", event_label(event_name)).bold().into()]; + match review_needed_count { + 0 => lines.push( + "Turn hooks on or off. Your changes are saved automatically." + .dim() + .into(), + ), + 1 => lines.push("1 hook needs review before it can run.".dim().into()), + count => lines.push( + format!("{count} hooks need review before they can run.") + .dim() + .into(), + ), + } + lines + } + + fn review_needed_count(&self, event_name: HookEventName) -> usize { + self.handlers_for_event(event_name) + .filter(|hook| hook_needs_review(hook)) + .count() } fn event_table_lines(&self) -> Vec> { + let rows = self.event_rows(); + let show_review = rows.iter().any(|row| row.needs_review > 0); let mut lines = Vec::new(); - lines.push(Line::from(vec![ + let mut header = vec![ format!("{: { + format!("[{marker}] {} · modified", hook_title(idx)) + } + HookTrustStatus::Untrusted => format!("[{marker}] {} · new", hook_title(idx)), + HookTrustStatus::Managed | HookTrustStatus::Trusted => { + format!("[{marker}] {}", hook_title(idx)) + } + }; let mut line = Line::from(row); line = truncate_line_with_ellipsis_if_overflow(line, width); if hook.is_managed { @@ -335,6 +414,7 @@ impl HooksBrowserView { Some(MAX_COMMAND_DETAIL_LINES), )); lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec))); + lines.push(detail_line("Trust", hook_trust_label(hook.trust_status))); lines } @@ -367,6 +447,14 @@ impl HooksBrowserView { key_hint::plain(KeyCode::Esc).into(), " to go back".into(), ]) + } else if selected_hook.is_some_and(hook_needs_review) { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char('t')).into(), + " to trust; ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) } else { Line::from(vec![ "Press ".into(), @@ -427,6 +515,15 @@ impl BottomPaneView for HooksBrowserView { self.toggle_selected_hook(event_name); } } + KeyEvent { + code: KeyCode::Char('t'), + modifiers: KeyModifiers::NONE, + .. + } => { + if let HooksBrowserPage::Handlers(event_name) = self.page { + self.trust_selected_hook(event_name); + } + } KeyEvent { code: KeyCode::Esc, .. } => match self.page { @@ -458,11 +555,14 @@ impl Renderable for HooksBrowserView { HooksBrowserPage::Events => self.event_page_lines().len(), HooksBrowserPage::Handlers(event_name) => { let row_count = self.handler_row_lines(event_name, content_width).len(); + let header_line_count = + Self::handler_header_lines(event_name, self.review_needed_count(event_name)) + .len(); if row_count == 0 { - Self::handler_header_lines(event_name).len() + 2 + header_line_count + 2 } else { let visible_row_count = row_count.min(MAX_POPUP_ROWS); - Self::handler_header_lines(event_name).len() + header_line_count + 1 + visible_row_count + 1 @@ -485,7 +585,8 @@ impl Renderable for HooksBrowserView { let lines = match self.page { HooksBrowserPage::Events => self.event_page_lines(), HooksBrowserPage::Handlers(event_name) => { - let mut lines = Self::handler_header_lines(event_name); + let mut lines = + Self::handler_header_lines(event_name, self.review_needed_count(event_name)); let rows = self.handler_row_lines(event_name, width); if rows.is_empty() { lines.push(Line::default()); @@ -525,10 +626,35 @@ impl Renderable for HooksBrowserView { } } +fn hook_is_active(hook: &HookMetadata) -> bool { + hook.enabled + && matches!( + hook.trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + ) +} + struct EventRow { event_name: HookEventName, installed: usize, active: usize, + needs_review: usize, +} + +fn hook_needs_review(hook: &HookMetadata) -> bool { + matches!( + hook.trust_status, + HookTrustStatus::Untrusted | HookTrustStatus::Modified + ) +} + +fn hook_trust_label(status: HookTrustStatus) -> &'static str { + match status { + HookTrustStatus::Managed => "Managed", + HookTrustStatus::Trusted => "Trusted", + HookTrustStatus::Untrusted => "New hook - review required", + HookTrustStatus::Modified => "Modified since last trusted - review required", + } } fn event_label(event_name: HookEventName) -> &'static str { @@ -661,6 +787,7 @@ mod tests { use codex_app_server_protocol::HookHandlerType; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; + use codex_app_server_protocol::HookTrustStatus; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use insta::assert_snapshot; @@ -706,6 +833,7 @@ mod tests { is_managed: bool, display_order: i64, ) -> HookMetadata { + let current_hash = "sha256:current".to_string(); HookMetadata { key: key.to_string(), event_name, @@ -720,6 +848,12 @@ mod tests { plugin_id: plugin_id.map(str::to_string), display_order, enabled, + current_hash, + trust_status: if is_managed { + HookTrustStatus::Managed + } else { + HookTrustStatus::Trusted + }, } } @@ -770,6 +904,33 @@ mod tests { assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112)); } + #[test] + fn renders_event_browser_with_review_column_when_needed() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + assert_snapshot!( + "hooks_browser_events_with_review_column", + render_lines(&view, /*width*/ 112) + ); + } + #[test] fn renders_event_browser_with_issues() { let (tx_raw, _rx) = unbounded_channel::(); @@ -796,6 +957,34 @@ mod tests { assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112)); } + #[test] + fn renders_untrusted_enabled_handler_as_inactive() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "~/bin/untrusted.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let mut view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_snapshot!( + "hooks_browser_untrusted_enabled_handler", + render_lines(&view, /*width*/ 112) + ); + } + #[test] fn renders_managed_handler_without_toggle_hint() { let mut view = view(); @@ -928,7 +1117,7 @@ mod tests { HookSource::System, /*plugin_id*/ None, "/enterprise/hooks/pre-tool-use-check.sh", - /*enabled*/ false, + /*enabled*/ true, /*is_managed*/ true, /*display_order*/ 0, )], @@ -947,6 +1136,93 @@ mod tests { assert_eq!(pre_tool_use.active, 1); } + #[test] + fn review_needed_hooks_are_not_active() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + let rows = view.event_rows(); + let pre_tool_use = rows + .into_iter() + .find(|row| row.event_name == HookEventName::PreToolUse) + .expect("pre tool use row"); + + assert_eq!(pre_tool_use.installed, 1); + assert_eq!(pre_tool_use.active, 0); + assert_eq!(pre_tool_use.needs_review, 1); + } + + #[test] + fn review_needed_event_is_selected_by_default() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PermissionRequest, + HookSource::User, + /*plugin_id*/ None, + "/tmp/permission-request-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + assert_eq!( + view.selected_event(), + Some(HookEventName::PermissionRequest) + ); + } + + #[test] + fn renders_review_needed_handler() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let mut view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_snapshot!( + "hooks_browser_review_needed_handler", + render_lines(&view, /*width*/ 112) + ); + } + fn assert_unmanaged_toggle_key(key_code: KeyCode) { let (tx_raw, mut rx) = unbounded_channel::(); let mut view = HooksBrowserView::new( @@ -1007,6 +1283,81 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[test] + fn trust_key_trusts_review_needed_handler_without_changing_enablement() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut untrusted_hook = hook( + "path:untrusted", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + untrusted_hook.trust_status = HookTrustStatus::Untrusted; + let current_hash = untrusted_hook.current_hash.clone(); + let mut view = HooksBrowserView::new( + vec![untrusted_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); + + match rx.try_recv().expect("trust event") { + AppEvent::TrustHook { + key, + current_hash: hash_to_trust, + } => { + assert_eq!(key, "path:untrusted"); + assert_eq!(hash_to_trust, current_hash); + } + other => panic!("expected hook trust event, got {other:?}"), + } + } + + #[test] + fn trust_key_preserves_disabled_modified_handler() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut modified_hook = hook( + "path:modified", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "/tmp/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 0, + ); + modified_hook.trust_status = HookTrustStatus::Modified; + let current_hash = modified_hook.current_hash.clone(); + let mut view = HooksBrowserView::new( + vec![modified_hook], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char('t'))); + + let hook = view.hooks.first().expect("trusted hook"); + assert!(!hook.enabled); + assert_eq!(hook.trust_status, HookTrustStatus::Trusted); + match rx.try_recv().expect("trust event") { + AppEvent::TrustHook { + key, + current_hash: hash_to_trust, + } => { + assert_eq!(key, "path:modified"); + assert_eq!(hash_to_trust, current_hash); + } + other => panic!("expected hook trust event, got {other:?}"), + } + } + #[test] fn escape_returns_to_the_selected_event() { let mut view = view(); diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index f307c28d72..95e8e8ca1e 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -12,6 +12,15 @@ use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_protocol::ThreadId; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as APPROVAL_META_KIND_KEY; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as APPROVAL_META_KIND_MCP_TOOL_CALL; +use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as APPROVAL_META_KIND_TOOL_SUGGESTION; +use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as APPROVAL_PERSIST_ALWAYS_VALUE; +use codex_protocol::mcp_approval_meta::PERSIST_KEY as APPROVAL_PERSIST_KEY; +use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE; +use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY; +use codex_protocol::mcp_approval_meta::TOOL_PARAMS_DISPLAY_KEY as APPROVAL_TOOL_PARAMS_DISPLAY_KEY; +use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as APPROVAL_TOOL_PARAMS_KEY; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -55,19 +64,10 @@ const APPROVAL_ACCEPT_SESSION_VALUE: &str = "accept_session"; const APPROVAL_ACCEPT_ALWAYS_VALUE: &str = "accept_always"; const APPROVAL_DECLINE_VALUE: &str = "decline"; const APPROVAL_CANCEL_VALUE: &str = "cancel"; -const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind"; -const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; -const APPROVAL_META_KIND_TOOL_SUGGESTION: &str = "tool_suggestion"; -const APPROVAL_PERSIST_KEY: &str = "persist"; -const APPROVAL_PERSIST_SESSION_VALUE: &str = "session"; -const APPROVAL_PERSIST_ALWAYS_VALUE: &str = "always"; -const APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; -const APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; const APPROVAL_TOOL_PARAM_DISPLAY_LIMIT: usize = 3; const APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES: usize = 60; const TOOL_TYPE_KEY: &str = "tool_type"; const TOOL_ID_KEY: &str = "tool_id"; -const TOOL_NAME_KEY: &str = "tool_name"; const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type"; const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason"; const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url"; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c839ddd4a7..71075f3d06 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,6 +17,7 @@ use std::collections::VecDeque; use std::path::PathBuf; use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::pending_input_preview::PendingInputPreview; @@ -36,6 +37,7 @@ use codex_core_skills::model::SkillMetadata; use codex_features::Features; use codex_file_search::FileMatch; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -206,6 +208,7 @@ pub(crate) struct BottomPane { app_event_tx: AppEventSender, frame_requester: FrameRequester, + thread_id: Option, has_input_focus: bool, enhanced_keys_supported: bool, @@ -271,6 +274,7 @@ impl BottomPane { last_composer_activity_at: None, app_event_tx, frame_requester, + thread_id: None, has_input_focus, enhanced_keys_supported, disable_paste_burst, @@ -776,7 +780,16 @@ impl BottomPane { } pub(crate) fn clear_composer_for_ctrl_c(&mut self) { - self.composer.clear_for_ctrl_c(); + if let Some(text) = self.composer.clear_for_ctrl_c() { + if let Some(thread_id) = self.thread_id { + self.app_event_tx + .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); + } else { + tracing::warn!( + "failed to append Ctrl+C-cleared draft to history: no active thread id" + ); + } + } self.request_redraw(); } @@ -1315,6 +1328,12 @@ impl BottomPane { AppLinkSuggestionType::Enable => { "Enable this app to use it for the current request.".to_string() } + AppLinkSuggestionType::Auth => unreachable!( + "auth uses URL mode elicitation, not tool suggestion forms" + ), + AppLinkSuggestionType::ExternalAction => unreachable!( + "external actions use URL mode elicitation, not tool suggestion forms" + ), }, url: install_url, is_installed, @@ -1420,8 +1439,15 @@ impl BottomPane { // --- History helpers --- - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.composer.set_history_metadata(log_id, entry_count); + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.thread_id = Some(thread_id); + self.composer + .set_history_metadata(thread_id, log_id, entry_count); } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { @@ -2408,6 +2434,7 @@ mod tests { policy: None, path_to_skills_md: test_path_buf("/tmp/test-skill/SKILL.md").abs(), scope: crate::test_support::skill_scope_user(), + plugin_id: None, }]), }); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 9f2c33fbec..c253d49b04 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -167,6 +167,7 @@ mod tests { vec![ SlashCommand::Ide, SlashCommand::Copy, + SlashCommand::Raw, SlashCommand::Diff, SlashCommand::Mention, SlashCommand::Status, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap new file mode 100644 index 0000000000..0cf228c129 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + + Reconnect Google Calendar on ChatGPT. + + URL + https://chatgpt.com/apps/google-calendar/connector_google_calendar + + Sign in to this app in your browser, then return here. + + + › 1. Open sign-in URL + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap new file mode 100644 index 0000000000..ec95ab96fa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Action required + Server: payments + + Review the payment details to continue. + + URL + https://payments.example/checkout/123 + + Complete the requested action in your browser, then return here. + + + › 1. Open link + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap new file mode 100644 index 0000000000..14236e2e76 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Finish in Browser + + Complete the requested action in the browser window that just + opened. + Then return here and select "I finished". + + Link: + https://payments.example/checkout/123 + + › 1. I finished + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap index 7af93e3c5a..808b9dedbf 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap @@ -15,5 +15,6 @@ expression: "render_lines(&view, 44)" seven eight nine ten eleven twelve thirteen fourteen… Timeout 30s + Trust Trusted Press space or enter to toggle; esc to go diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap new file mode 100644 index 0000000000..85e930c68c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + Event Installed Active Review Description + PreToolUse 1 0 1 Before a tool executes + PermissionRequest 0 0 0 When permission is requested + PostToolUse 0 0 0 After a tool executes + SessionStart 0 0 0 When a new session starts + UserPromptSubmit 0 0 0 When the user submits a prompt + Stop 0 0 0 Right before Codex ends its turn + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap index c44f4b866a..6e88734980 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -14,5 +14,6 @@ expression: "render_lines(&view, 112)" Source Plugin - superpowers@openai-curated Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh Timeout 30s + Trust Trusted Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap index 21c59065f5..d073b11b3c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap @@ -13,5 +13,6 @@ expression: "render_lines(&view, 112)" Source Admin config Command /enterprise/hooks/permission-check.sh Timeout 30s + Trust Managed Managed hooks are always on; press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_review_needed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_review_needed_handler.snap new file mode 100644 index 0000000000..b4a5c117e1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_review_needed_handler.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + 1 hook needs review before it can run. + + [!] Hook 1 · new + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command /tmp/pre-tool-use-check.sh + Timeout 30s + Trust New hook - review required + + Press t to trust; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap index 4f4a4377c6..efeb0b2405 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -20,5 +20,6 @@ expression: "render_lines(&view, 112)" Source User config - /tmp/hooks.json Command /tmp/hook-8.sh Timeout 30s + Trust Trusted Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap index 9a53b95d6d..514a8917a4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap @@ -14,5 +14,6 @@ expression: "render_lines(&view, 112)" Source Admin config Command /enterprise/hooks/pre-tool-use-2.sh Timeout 30s + Trust Managed Managed hooks are always on; press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_untrusted_enabled_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_untrusted_enabled_handler.snap new file mode 100644 index 0000000000..4fa01776f6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_untrusted_enabled_handler.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + 1 hook needs review before it can run. + + [!] Hook 1 · new + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command ~/bin/untrusted.sh + Timeout 30s + Trust New hook - review required + + Press t to trust; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 5d0ba8718e..db789a11ff 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -117,6 +117,9 @@ pub(crate) enum StatusLineItem { /// Whether Fast mode is currently active. FastMode, + /// Whether raw scrollback mode is currently active. + RawOutput, + /// Current thread title (if set by user). ThreadTitle, @@ -163,6 +166,7 @@ impl StatusLineItem { "Current session identifier (omitted until session starts)" } StatusLineItem::FastMode => "Whether Fast mode is currently active", + StatusLineItem::RawOutput => "Whether raw scrollback mode is active", StatusLineItem::ThreadTitle => "Current thread title (omitted when unavailable)", StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" @@ -191,6 +195,7 @@ impl StatusLineItem { StatusLineItem::TotalOutputTokens => StatusSurfacePreviewItem::TotalOutputTokens, StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId, StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, + StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index dddd02db09..07018bff0e 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -44,7 +44,7 @@ impl StatusLineAccent { | StatusLineItem::TotalOutputTokens => Self::Usage, StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit, StatusLineItem::CodexVersion | StatusLineItem::SessionId => Self::Metadata, - StatusLineItem::FastMode => Self::Mode, + StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::ThreadTitle => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 581d424aaf..1f23742a55 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -27,6 +27,7 @@ pub(crate) enum StatusSurfacePreviewItem { TotalOutputTokens, SessionId, FastMode, + RawOutput, Model, ModelWithReasoning, TaskProgress, @@ -55,6 +56,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::TotalOutputTokens => "0 out", StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::FastMode => "Fast on", + StatusSurfacePreviewItem::RawOutput => "raw output", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0", @@ -83,6 +85,7 @@ impl StatusSurfacePreviewItem { Self::TotalOutputTokens, Self::SessionId, Self::FastMode, + Self::RawOutput, Self::Model, Self::ModelWithReasoning, Self::TaskProgress, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b88d961ec4..b00d9df42a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -293,6 +293,7 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::history_cell::HistoryRenderMode; use crate::history_cell::HookCell; use crate::history_cell::McpInvocation; use crate::history_cell::McpToolCallCell; @@ -320,6 +321,7 @@ use self::goal_status::GoalStatusState; #[cfg(test)] use self::goal_status::goal_status_indicator_from_app_goal; mod goal_menu; +mod goal_validation; mod ide_context; use self::ide_context::IdeContextState; mod interrupts; @@ -349,6 +351,8 @@ use self::status_surfaces::TerminalTitleStatusKind; mod user_messages; use self::user_messages::PendingSteerCompareKey; use self::user_messages::UserMessageDisplay; +mod warnings; +use self::warnings::WarningDisplayState; pub(crate) use crate::branch_summary::StatusLineGitSummary; use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::streaming::commit_tick::CommitTickScope; @@ -755,6 +759,7 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, + raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. effective_service_tier: Option, /// The unmasked collaboration mode settings (always Default mode). @@ -777,6 +782,7 @@ pub(crate) struct ChatWidget { plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, + warning_display_state: WarningDisplayState, rate_limit_switch_prompt: RateLimitSwitchPromptState, add_credits_nudge_email_in_flight: Option, adaptive_chunking: AdaptiveChunkingPolicy, @@ -2040,10 +2046,12 @@ impl ChatWidget { self.visible_user_turn_count = 0; self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = false; - let history_entry_count = - usize::try_from(session.history_entry_count).unwrap_or(usize::MAX); - self.bottom_pane - .set_history_metadata(session.history_log_id, history_entry_count); + let history_metadata = session.message_history.unwrap_or_default(); + self.bottom_pane.set_history_metadata( + session.thread_id, + history_metadata.log_id, + history_metadata.entry_count, + ); self.set_skills(/*skills*/ None); self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; @@ -2063,7 +2071,10 @@ impl ChatWidget { self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); - self.effective_service_tier = session.service_tier; + self.effective_service_tier = session + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); if let Err(err) = self .config .permissions @@ -2112,7 +2123,7 @@ impl ChatWidget { if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = - self.should_show_fast_status(&model_for_header, session.service_tier); + self.should_show_fast_status(&model_for_header, self.effective_service_tier); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -2328,6 +2339,7 @@ impl ChatWidget { self.plan_stream_controller = Some(PlanStreamController::new( self.current_stream_width(/*reserved_cols*/ 4), &self.config.cwd, + self.history_render_mode(), )); } if let Some(controller) = self.plan_stream_controller.as_mut() @@ -3124,7 +3136,11 @@ impl ChatWidget { } fn on_warning(&mut self, message: impl Into) { - self.add_to_history(history_cell::new_warning_event(message.into())); + let message = message.into(); + if !self.warning_display_state.should_display(&message) { + return; + } + self.add_to_history(history_cell::new_warning_event(message)); self.request_redraw(); } @@ -4030,7 +4046,7 @@ impl ChatWidget { entry, } = event; self.bottom_pane - .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); + .on_history_entry_response(log_id, offset, entry); } fn on_shutdown_complete(&mut self) { @@ -4330,6 +4346,7 @@ impl ChatWidget { self.stream_controller = Some(StreamController::new( self.current_stream_width(/*reserved_cols*/ 2), &self.config.cwd, + self.history_render_mode(), )); } if let Some(controller) = self.stream_controller.as_mut() @@ -4552,7 +4569,14 @@ impl ChatWidget { }); let thread_id = self.thread_id.unwrap_or_default(); - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( + thread_id, + ¶ms.server_name, + request_id.clone(), + ¶ms.request, + ) { + self.open_app_link_view(params); + } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id.clone(), params.clone(), @@ -4560,18 +4584,29 @@ impl ChatWidget { self.bottom_pane .push_mcp_server_elicitation_request(request); } else { - let request = ApprovalRequest::McpElicitation { - thread_id, - thread_label: None, - server_name: params.server_name, - request_id, - message: match params.request { - McpServerElicitationRequest::Form { message, .. } - | McpServerElicitationRequest::Url { message, .. } => message, - }, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); + match params.request { + McpServerElicitationRequest::Form { message, .. } => { + let request = ApprovalRequest::McpElicitation { + thread_id, + thread_label: None, + server_name: params.server_name, + request_id, + message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + } + McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name, + request_id, + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + } + } } self.request_redraw(); } @@ -4857,7 +4892,10 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.to_path_buf()); - let effective_service_tier = config.service_tier; + let effective_service_tier = config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); @@ -4889,6 +4927,7 @@ impl ChatWidget { }), active_cell, active_cell_revision: 0, + raw_output_mode: config.tui_raw_output_mode, config, effective_service_tier, skills_all: Vec::new(), @@ -4909,6 +4948,7 @@ impl ChatWidget { plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), + warning_display_state: WarningDisplayState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), add_credits_nudge_email_in_flight: None, adaptive_chunking: AdaptiveChunkingPolicy::default(), @@ -5558,7 +5598,7 @@ impl ChatWidget { ) -> QueueDrain { let drain = self.submit_shell_command(command); if drain == QueueDrain::Stop { - self.submit_op(AppCommand::add_to_history(history_text.to_string())); + self.append_message_history_entry(history_text.to_string()); } drain } @@ -5844,7 +5884,7 @@ impl ChatWidget { .personality .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); - let service_tier = match self.config.service_tier { + let service_tier = match self.config.service_tier.clone() { Some(service_tier) => Some(Some(service_tier)), None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, @@ -5892,7 +5932,7 @@ impl ChatWidget { } }; if let Some(history_text) = history_text { - self.submit_op(AppCommand::add_to_history(history_text)); + self.append_message_history_entry(history_text); } if let Some(pending_steer) = pending_steer { @@ -5961,6 +6001,7 @@ impl ChatWidget { for turn in turns { let Turn { id: turn_id, + items_view: _, items, status, error, @@ -5984,6 +6025,7 @@ impl ChatWidget { thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: Turn { id: turn_id, + items_view: codex_app_server_protocol::TurnItemsView::NotLoaded, items: Vec::new(), status, error, @@ -9254,7 +9296,8 @@ impl ChatWidget { /// Set Fast mode in the widget's config copy. pub(crate) fn set_service_tier(&mut self, service_tier: Option) { - self.config.service_tier = service_tier; + self.config.service_tier = + service_tier.map(|service_tier| service_tier.request_value().to_string()); self.effective_service_tier = service_tier; } @@ -9263,7 +9306,10 @@ impl ChatWidget { } pub(crate) fn configured_service_tier(&self) -> Option { - self.config.service_tier + self.config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value) } pub(crate) fn fast_default_opt_out(&self) -> Option { @@ -9370,7 +9416,7 @@ impl ChatWidget { /*model*/ None, /*effort*/ None, /*summary*/ None, - Some(service_tier), + Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), /*collaboration_mode*/ None, /*personality*/ None, ))); @@ -10278,6 +10324,53 @@ impl ChatWidget { }) } + pub(crate) fn raw_output_mode(&self) -> bool { + self.raw_output_mode + } + + pub(crate) fn history_render_mode(&self) -> HistoryRenderMode { + if self.raw_output_mode { + HistoryRenderMode::Raw + } else { + HistoryRenderMode::Rich + } + } + + pub(crate) fn set_raw_output_mode(&mut self, enabled: bool) { + self.raw_output_mode = enabled; + self.config.tui_raw_output_mode = enabled; + let render_mode = self.history_render_mode(); + if let Some(controller) = self.stream_controller.as_mut() { + controller.set_render_mode(render_mode); + } + if let Some(controller) = self.plan_stream_controller.as_mut() { + controller.set_render_mode(render_mode); + } + self.refresh_status_surfaces(); + } + + pub(crate) fn raw_output_mode_notice(enabled: bool) -> &'static str { + if enabled { + "Raw output mode on: transcript text is shown for clean terminal selection." + } else { + "Raw output mode off: rich transcript rendering restored." + } + } + + pub(crate) fn set_raw_output_mode_and_notify(&mut self, enabled: bool) { + self.set_raw_output_mode(enabled); + self.add_info_message( + Self::raw_output_mode_notice(enabled).to_string(), + /*hint*/ None, + ); + } + + pub(crate) fn toggle_raw_output_mode_and_notify(&mut self) -> bool { + let enabled = !self.raw_output_mode; + self.set_raw_output_mode_and_notify(enabled); + enabled + } + /// Update resize-sensitive chat widget state after the terminal width changes. /// /// The app calls this even when terminal resize reflow is disabled so live stream wrapping @@ -10470,6 +10563,15 @@ impl ChatWidget { true } + fn append_message_history_entry(&self, text: String) { + let Some(thread_id) = self.thread_id else { + tracing::warn!("failed to append to message history: no active thread id"); + return; + }; + self.app_event_tx + .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); + } + pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { if matches!(op, AppCommand::Interrupt) && self.agent_turn_running { if let Some(controller) = self.stream_controller.as_mut() { diff --git a/codex-rs/tui/src/chatwidget/goal_validation.rs b/codex-rs/tui/src/chatwidget/goal_validation.rs new file mode 100644 index 0000000000..2f9bcb931c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/goal_validation.rs @@ -0,0 +1,64 @@ +//! Validation helpers for `/goal` objective text. + +use super::*; +use crate::bottom_pane::ChatComposer; +use codex_protocol::num_format::format_with_separators; +use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; + +const GOAL_TOO_LONG_FILE_HINT: &str = "Put longer instructions in a file and refer to that file in the goal, for example: /goal follow the instructions in docs/goal.md."; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum GoalObjectiveValidationSource { + Live, + Queued, +} + +impl ChatWidget { + pub(super) fn goal_objective_with_pending_pastes_is_allowed( + &mut self, + args: &str, + text_elements: &[TextElement], + ) -> bool { + let pending_pastes = self.bottom_pane.composer_pending_pastes(); + let objective_chars = if pending_pastes.is_empty() { + args.trim().chars().count() + } else { + let (expanded, _) = + ChatComposer::expand_pending_pastes(args, text_elements.to_vec(), &pending_pastes); + expanded.trim().chars().count() + }; + self.goal_objective_char_count_is_allowed( + objective_chars, + GoalObjectiveValidationSource::Live, + ) + } + + pub(super) fn goal_objective_is_allowed( + &mut self, + objective: &str, + source: GoalObjectiveValidationSource, + ) -> bool { + self.goal_objective_char_count_is_allowed(objective.chars().count(), source) + } + + fn goal_objective_char_count_is_allowed( + &mut self, + actual_chars: usize, + source: GoalObjectiveValidationSource, + ) -> bool { + if actual_chars <= MAX_THREAD_GOAL_OBJECTIVE_CHARS { + return true; + } + let actual_chars = format_with_separators(actual_chars as i64); + let max_chars = format_with_separators(MAX_THREAD_GOAL_OBJECTIVE_CHARS as i64); + self.add_error_message(format!( + "Goal objective is too long: {actual_chars} characters. Limit: {max_chars} characters. {GOAL_TOO_LONG_FILE_HINT}" + )); + if source == GoalObjectiveValidationSource::Live { + self.bottom_pane + .set_composer_text(String::new(), Vec::new(), Vec::new()); + self.bottom_pane.drain_pending_submission_state(); + } + false + } +} diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 71ef567e3d..9c11151f0f 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -234,6 +234,7 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> Option { self.copy_last_agent_markdown(); } + SlashCommand::Raw => { + let enabled = self.toggle_raw_output_mode_and_notify(); + self.emit_raw_output_mode_changed(enabled); + } SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); + let runner = self.workspace_command_runner.clone(); + let cwd = self + .current_cwd + .clone() + .unwrap_or_else(|| self.config.cwd.to_path_buf()); tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() + let text = match runner { + Some(runner) => match get_git_diff(runner.as_ref(), &cwd).await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } } - } - Err(e) => format!("Failed to compute diff: {e}"), + Err(e) => format!("Failed to compute diff: {e}"), + }, + None => "Failed to compute diff: workspace command runner unavailable" + .to_string(), }; tx.send(AppEvent::DiffResult(text)); }); @@ -475,6 +495,12 @@ impl ChatWidget { return; } + if cmd == SlashCommand::Goal + && !self.goal_objective_with_pending_pastes_is_allowed(&args, &text_elements) + { + return; + } + let Some((prepared_args, prepared_elements)) = self.prepare_live_inline_args(args, text_elements) else { @@ -588,6 +614,17 @@ impl ChatWidget { } _ => self.add_error_message("Usage: /keymap [debug]".to_string()), }, + SlashCommand::Raw => match trimmed.to_ascii_lowercase().as_str() { + "on" => { + self.set_raw_output_mode_and_notify(/*enabled*/ true); + self.emit_raw_output_mode_changed(/*enabled*/ true); + } + "off" => { + self.set_raw_output_mode_and_notify(/*enabled*/ false); + self.emit_raw_output_mode_changed(/*enabled*/ false); + } + _ => self.add_error_message(RAW_USAGE.to_string()), + }, SlashCommand::Rename if !trimmed.is_empty() => { if !self.ensure_thread_rename_allowed() { return; @@ -672,6 +709,13 @@ impl ChatWidget { } return; } + let validation_source = match source { + SlashCommandDispatchSource::Live => GoalObjectiveValidationSource::Live, + SlashCommandDispatchSource::Queued => GoalObjectiveValidationSource::Queued, + }; + if !self.goal_objective_is_allowed(objective, validation_source) { + return; + } let Some(thread_id) = self.thread_id else { if source == SlashCommandDispatchSource::Live { self.queue_user_message_with_options( @@ -804,6 +848,11 @@ impl ChatWidget { rest_offset + leading_trimmed, &text_elements, ); + if cmd == SlashCommand::Goal + && !self.goal_objective_is_allowed(trimmed_rest, GoalObjectiveValidationSource::Queued) + { + return QueueDrain::Continue; + } self.dispatch_prepared_command_with_args( cmd, PreparedSlashCommandArgs { @@ -859,6 +908,7 @@ impl ChatWidget { | SlashCommand::Plugins | SlashCommand::Rollout | SlashCommand::Copy + | SlashCommand::Raw | SlashCommand::Vim | SlashCommand::Diff | SlashCommand::Rename diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_slash_command_oversized_objective_error.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_slash_command_oversized_objective_error.snap new file mode 100644 index 0000000000..470beccf0e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_slash_command_oversized_objective_error.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/goal_validation.rs +expression: rendered +--- +■ Goal objective is too long: 4,001 characters. Limit: 4,000 characters. Put longer instructions in a file and refer to that file in the goal, for example: /goal follow the instructions in docs/goal.md. diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 699b45e053..3095556fd0 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -647,6 +647,7 @@ impl ChatWidget { "Fast off".to_string() }, ), + StatusLineItem::RawOutput => self.raw_output_mode().then(|| "raw output".to_string()), StatusLineItem::ThreadTitle => self.thread_name.as_ref().and_then(|name| { let trimmed = name.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) @@ -688,6 +689,7 @@ impl ChatWidget { StatusSurfacePreviewItem::TotalOutputTokens => StatusLineItem::TotalOutputTokens, StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, + StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, }; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 323d0b749e..8ed8ca7db3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -226,6 +226,7 @@ mod approval_requests; mod composer_submission; mod exec_flow; mod goal_menu; +mod goal_validation; mod guardian; mod helpers; mod history_replay; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index a486c0e0ea..059366791e 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,42 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn invalid_url_elicitation_is_declined() { + let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + + chat.handle_elicitation_request_now( + codex_app_server_protocol::RequestId::Integer(9), + codex_app_server_protocol::McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + ); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: codex_app_server_protocol::RequestId::Integer(9), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -116,6 +152,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -159,6 +196,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, @@ -183,6 +221,7 @@ async fn live_app_server_turn_started_sets_feedback_turn_id() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -542,6 +581,7 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -576,6 +616,7 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::Failed, error: Some(AppServerTurnError { @@ -604,6 +645,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -661,6 +703,7 @@ async fn live_app_server_server_overloaded_error_renders_warning() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -702,6 +745,7 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index dfab8a2cad..10d3e5549e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -12,10 +12,10 @@ use pretty_assertions::assert_eq; async fn submission_preserves_text_elements_and_local_images() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -29,8 +29,7 @@ async fn submission_preserves_text_elements_and_local_images() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -95,7 +94,7 @@ async fn submission_preserves_text_elements_and_local_images() { async fn submission_includes_configured_permission_profile() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, @@ -119,7 +118,7 @@ async fn submission_includes_configured_permission_profile() { } .into(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -133,8 +132,7 @@ async fn submission_includes_configured_permission_profile() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -161,7 +159,7 @@ async fn submission_includes_configured_permission_profile() { async fn submission_keeps_profile_when_legacy_projection_is_external() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, @@ -169,7 +167,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { } .into(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -183,8 +181,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -208,10 +205,10 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -225,8 +222,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -302,10 +298,10 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi async fn enter_with_only_remote_images_submits_user_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -319,8 +315,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -366,10 +361,10 @@ async fn enter_with_only_remote_images_submits_user_turn() { async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -383,8 +378,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -405,10 +399,10 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -422,8 +416,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -444,10 +437,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -461,8 +454,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -486,10 +478,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { async fn submission_prefers_selected_duplicate_skill_path() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -503,8 +495,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -523,6 +514,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { policy: None, path_to_skills_md: repo_skill_path, scope: crate::test_support::skill_scope_repo(), + plugin_id: None, }, SkillMetadata { name: "figma".to_string(), @@ -533,6 +525,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { policy: None, path_to_skills_md: user_skill_path.clone(), scope: crate::test_support::skill_scope_user(), + plugin_id: None, }, ])); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index c9789be14d..b1949d7809 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -941,10 +941,10 @@ async fn user_shell_command_renders_output_not_exploring() { #[tokio::test] async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -958,8 +958,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -977,8 +976,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { other => panic!("expected RunUserShellCommand op, got {other:?}"), } assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" + rx.try_recv(), + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "!echo hi" ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } diff --git a/codex-rs/tui/src/chatwidget/tests/goal_validation.rs b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs new file mode 100644 index 0000000000..85ac34ebdd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs @@ -0,0 +1,224 @@ +use super::*; +use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use pretty_assertions::assert_eq; + +fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) { + if let Some(message) = message { + complete_assistant_message( + chat, + &format!("{turn_id}-message"), + message, + Some(MessagePhase::FinalAnswer), + ); + } + handle_turn_completed(chat, turn_id, /*duration_ms*/ None); +} + +fn submit_composer_text(chat: &mut ChatWidget, text: &str) { + chat.bottom_pane + .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + submit_current_composer(chat); +} + +fn submit_current_composer(chat: &mut ChatWidget) { + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); +} + +fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) { + chat.bottom_pane + .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); +} + +fn drain_app_events(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Vec { + std::iter::from_fn(|| rx.try_recv().ok()).collect() +} + +fn rendered_insert_history(events: &[AppEvent]) -> String { + events + .iter() + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n") +} + +#[tokio::test] +async fn goal_slash_command_accepts_objective_at_limit() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS); + let command = format!("/goal {objective}"); + + submit_composer_text(&mut chat, &command); + + let event = rx.try_recv().expect("expected goal objective event"); + let AppEvent::SetThreadGoalObjective { + thread_id: actual_thread_id, + objective: actual_objective, + .. + } = event + else { + panic!("expected SetThreadGoalObjective, got {event:?}"); + }; + assert_eq!(actual_thread_id, thread_id); + assert_eq!(actual_objective, objective); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn goal_slash_command_accepts_multiline_objective_after_blank_first_line() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + let objective = "follow these instructions\npreserve this detail"; + + submit_composer_text(&mut chat, &format!("/goal \n\n{objective}")); + + let event = rx.try_recv().expect("expected goal objective event"); + let AppEvent::SetThreadGoalObjective { + thread_id: actual_thread_id, + objective: actual_objective, + .. + } = event + else { + panic!("expected SetThreadGoalObjective, got {event:?}"); + }; + assert_eq!(actual_thread_id, thread_id); + assert_eq!(actual_objective, objective); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn goal_slash_command_rejects_oversized_objective() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(ThreadId::new()); + let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1); + + submit_composer_text(&mut chat, &format!("/goal {objective}")); + + let events = drain_app_events(&mut rx); + assert!( + !events + .iter() + .any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })), + "oversized goal should not emit a SetThreadGoalObjective event: {events:?}" + ); + let rendered = rendered_insert_history(&events); + assert_chatwidget_snapshot!("goal_slash_command_oversized_objective_error", rendered); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn goal_slash_command_rejects_large_paste_using_expanded_length() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(ThreadId::new()); + chat.bottom_pane + .set_composer_text("/goal ".to_string(), Vec::new(), Vec::new()); + let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1); + chat.handle_paste(objective); + + assert!( + chat.bottom_pane.composer_text().contains("[Pasted Content"), + "expected large paste placeholder in composer" + ); + submit_current_composer(&mut chat); + + let events = drain_app_events(&mut rx); + assert!( + !events + .iter() + .any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })), + "oversized pasted goal should not emit a SetThreadGoalObjective event: {events:?}" + ); + let rendered = rendered_insert_history(&events); + assert!(rendered.contains("Goal objective is too long")); + assert!(rendered.contains("Put longer instructions in a file")); + assert!( + !rendered.contains("Message exceeds the maximum length"), + "expected goal-specific length error, got {rendered:?}" + ); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn goal_slash_command_giant_paste_uses_goal_specific_error() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(ThreadId::new()); + chat.bottom_pane + .set_composer_text("/goal ".to_string(), Vec::new(), Vec::new()); + chat.handle_paste("x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1)); + + submit_current_composer(&mut chat); + + let events = drain_app_events(&mut rx); + assert!( + !events + .iter() + .any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })), + "giant pasted goal should not emit a SetThreadGoalObjective event: {events:?}" + ); + let rendered = rendered_insert_history(&events); + assert!(rendered.contains("Goal objective is too long")); + assert!(rendered.contains("Put longer instructions in a file")); + assert!( + !rendered.contains("Message exceeds the maximum length"), + "expected goal-specific length error, got {rendered:?}" + ); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_input() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(ThreadId::new()); + handle_turn_started(&mut chat, "turn-1"); + let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1); + + queue_composer_text_with_tab(&mut chat, &format!("/goal {objective}")); + queue_composer_text_with_tab(&mut chat, "continue"); + assert_eq!(chat.queued_user_messages.len(), 2); + + complete_turn_with_message(&mut chat, "turn-1", Some("done")); + + let events = drain_app_events(&mut rx); + assert!( + !events + .iter() + .any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })), + "oversized queued goal should not emit a SetThreadGoalObjective event: {events:?}" + ); + let rendered = rendered_insert_history(&events); + assert!(rendered.contains("Goal objective is too long")); + assert!(rendered.contains("Put longer instructions in a file")); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "continue".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued follow-up after oversized goal, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); + assert_no_submit_op(&mut op_rx); +} diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 8f3d37e76c..307e9d7fab 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -182,13 +182,17 @@ pub(super) async fn make_chatwidget_manual( }; let current_collaboration_mode = base_mode; let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); - let effective_service_tier = cfg.service_tier; + let effective_service_tier = cfg + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); let mut widget = ChatWidget { app_event_tx, codex_op_target: super::CodexOpTarget::Direct(op_tx), bottom_pane: bottom, active_cell: None, active_cell_revision: 0, + raw_output_mode: cfg.tui_raw_output_mode, config: cfg, effective_service_tier, current_collaboration_mode, @@ -207,6 +211,7 @@ pub(super) async fn make_chatwidget_manual( plan_type: None, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), + warning_display_state: WarningDisplayState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), add_credits_nudge_email_in_flight: None, adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), @@ -1085,6 +1090,7 @@ pub(super) fn app_server_turn( ) -> AppServerTurn { AppServerTurn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status, error, @@ -1446,6 +1452,7 @@ pub(super) fn plugins_test_summary( description, /*long_description*/ None, )), + keywords: Vec::new(), } } diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 180412cae7..d3cd753986 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -14,10 +14,10 @@ use pretty_assertions::assert_eq; async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -31,8 +31,7 @@ async fn resumed_initial_messages_render_history() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -85,10 +84,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -102,8 +101,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -154,10 +152,10 @@ async fn replayed_user_message_preserves_remote_image_urls() { let message = "replayed with remote image".to_string(); let remote_image_urls = vec!["https://example.com/image.png".to_string()]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -171,8 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -271,8 +268,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { cwd: expected_cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; @@ -328,8 +324,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { cwd: test_path_buf("/home/user/external").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; @@ -350,10 +345,10 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -367,8 +362,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -405,10 +399,10 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { let local_images = [PathBuf::from("/tmp/replay-local-only.png")]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -422,8 +416,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -623,6 +616,7 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -692,8 +686,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }); @@ -738,8 +731,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }); @@ -774,6 +766,7 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -842,6 +835,7 @@ async fn replayed_in_progress_turn_marks_task_running() { chat.replay_thread_turns( vec![AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 09595a11ba..df3615c0fd 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -586,8 +586,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -634,8 +633,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w cwd, instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index d74192e594..c8bcd6c225 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -807,6 +807,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_thread_turns( vec![AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::AgentMessage { id: "msg-plan".to_string(), text: "Plan details".to_string(), @@ -844,6 +845,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_thread_turns( vec![AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::AgentMessage { id: "msg-plan-replay".to_string(), text: "Plan details".to_string(), @@ -1128,6 +1130,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { thread_id: thread_id.to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -1172,6 +1175,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { thread_id: thread_id.to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, @@ -1198,10 +1202,10 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { #[tokio::test(flavor = "multi_thread")] async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -1215,8 +1219,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -1460,8 +1463,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index a2c4f11d50..00f1b3044b 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2453,6 +2453,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { }], supports_personality: false, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), is_default: false, upgrade: None, show_in_picker, @@ -2673,6 +2674,7 @@ async fn single_reasoning_option_skips_selection() { supported_reasoning_efforts: single_effort, supports_personality: false, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), is_default: false, upgrade: None, show_in_picker: true, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 0924d724fa..f59e880dac 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -1163,6 +1163,7 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::InProgress, error: None, @@ -1199,6 +1200,7 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Interrupted, error: None, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2832377375..87ad226b51 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -16,6 +16,10 @@ fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Opt fn submit_composer_text(chat: &mut ChatWidget, text: &str) { chat.bottom_pane .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + submit_current_composer(chat); +} + +fn submit_current_composer(chat: &mut ChatWidget) { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -34,14 +38,16 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { chat.bottom_pane.composer_text() } -fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { +fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { loop { - match op_rx.try_recv() { - Ok(Op::AddToHistory { text }) => return text, + match rx.try_recv() { + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) => return text, Ok(_) => continue, - Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"), + Err(TryRecvError::Empty) => { + panic!("expected AppendMessageHistoryEntry event but queue was empty") + } Err(TryRecvError::Disconnected) => { - panic!("expected AddToHistory op but channel closed") + panic!("expected AppendMessageHistoryEntry event but channel closed") } } } @@ -112,15 +118,6 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() { complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { - Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() { - Ok(Op::Review { target }) => assert_eq!( - target, - ReviewTarget::Custom { - instructions: "check regressions".to_string(), - } - ), - other => panic!("expected queued /review to submit review op, got {other:?}"), - }, Ok(Op::Review { target }) => assert_eq!( target, ReviewTarget::Custom { @@ -148,7 +145,7 @@ async fn queued_slash_review_with_args_restores_for_edit() { #[tokio::test] async fn queued_bang_shell_dispatches_after_active_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -167,10 +164,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" - ); + assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert!(chat.queued_user_messages.is_empty()); } @@ -213,7 +207,7 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu #[tokio::test] async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -226,10 +220,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" - ); + assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert_eq!(chat.queued_user_messages.len(), 1); let begin = begin_exec_with_source( @@ -408,10 +399,10 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { ), other => panic!("expected first queued message after /rename, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "first after rename" - ); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::AppendMessageHistoryEntry { text, .. } if text == "first after rename" + ))); assert_eq!( chat.queued_user_message_texts(), vec!["second after rename"] @@ -941,7 +932,7 @@ fn merged_history_record_remaps_override_image_placeholders() { #[tokio::test] async fn interrupted_merged_message_history_encodes_mentions_once() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); chat.on_agent_message_delta("Final answer line\n".to_string()); @@ -973,7 +964,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { other => panic!("expected user turn, got {other:?}"), } let encoded = "use [$figma](app://figma) now"; - assert_eq!(next_add_to_history_op(&mut op_rx), encoded); + assert_eq!(next_add_to_history_event(&mut rx), encoded); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); next_interrupt_op(&mut op_rx); @@ -993,7 +984,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { } other => panic!("expected resubmitted user turn, got {other:?}"), } - assert_eq!(next_add_to_history_op(&mut op_rx), encoded); + assert_eq!(next_add_to_history_event(&mut rx), encoded); } #[tokio::test] @@ -1590,6 +1581,37 @@ async fn slash_clear_requests_ui_clear_when_idle() { assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); } +#[tokio::test] +async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.bottom_pane + .set_history_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 0); + + submit_composer_text(&mut chat, "ok"); + assert_eq!(next_add_to_history_event(&mut rx), "ok"); + + let stashed_draft = "explain why history recall lost this draft"; + + chat.bottom_pane + .set_composer_text(stashed_draft.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(chat.bottom_pane.composer_text(), ""); + assert_eq!(next_add_to_history_event(&mut rx), stashed_draft); + + chat.bottom_pane + .set_composer_text("/clear".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), stashed_draft); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "ok"); +} + #[tokio::test] async fn slash_clear_is_disabled_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1799,9 +1821,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); @@ -1830,9 +1852,9 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); @@ -1880,9 +1902,9 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { match next_submit_op(&mut op_rx) { Op::UserTurn { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - } => {} + } if service_tier == ServiceTier::Fast.request_value() => {} other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), } } @@ -1905,9 +1927,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected queued /fast to update service tier before next turn; events: {events:?}" ); @@ -1915,9 +1937,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() { match next_submit_op(&mut op_rx) { Op::UserTurn { items, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - } => assert_eq!( + } if service_tier == ServiceTier::Fast.request_value() => assert_eq!( items, vec![UserInput::Text { text: "hello after fast".to_string(), @@ -1971,6 +1993,57 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { } } +#[tokio::test] +async fn raw_slash_command_toggles_and_accepts_on_off_args() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Raw); + assert!(chat.raw_output_mode()); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true })) + ); + + chat.dispatch_command_with_args(SlashCommand::Raw, "off".to_string(), Vec::new()); + assert!(!chat.raw_output_mode()); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: false })) + ); + + chat.dispatch_command_with_args(SlashCommand::Raw, "on".to_string(), Vec::new()); + assert!(chat.raw_output_mode()); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true })) + ); +} + +#[tokio::test] +async fn raw_slash_command_reports_usage_for_invalid_arg() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Raw, "status".to_string(), Vec::new()); + + assert!(!chat.raw_output_mode()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Usage: /raw [on|off]"), + "expected raw usage error, got {rendered:?}" + ); +} + #[tokio::test] async fn compact_queues_user_messages_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; 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 440b7f9ab8..89bb715be5 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -156,6 +156,23 @@ async fn status_line_git_summary_items_render_values() { ); } +#[tokio::test] +async fn raw_output_status_line_value_only_shows_when_enabled() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput), + None + ); + + chat.set_raw_output_mode(/*enabled*/ true); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput), + Some("raw output".to_string()) + ); +} + #[tokio::test] async fn status_line_branch_changes_render_no_changes() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -196,6 +213,30 @@ async fn stale_status_line_git_summary_update_is_ignored() { assert!(chat.status_line_git_summary.is_none()); assert!(!chat.status_line_git_summary_pending); } + +#[tokio::test] +async fn raw_output_mode_can_change_without_inserting_notice() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.set_raw_output_mode(/*enabled*/ true); + + assert!(chat.raw_output_mode()); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.set_raw_output_mode_and_notify(/*enabled*/ false); + + assert!(!chat.raw_output_mode()); + let history = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + history.contains("Raw output mode off: rich transcript rendering restored."), + "expected raw output notice, got {history:?}" + ); +} + #[tokio::test] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); @@ -1282,6 +1323,34 @@ async fn warning_event_adds_warning_history_cell() { ); } +#[tokio::test] +async fn repeated_model_metadata_warning_is_hidden_for_same_slug() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let warning = "Model metadata for `unknown-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."; + + handle_warning(&mut chat, warning); + handle_warning(&mut chat, warning); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("unknown-model"), + "warning cell missing model slug: {rendered}" + ); +} + +#[tokio::test] +async fn repeated_generic_warning_is_not_hidden() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + handle_warning(&mut chat, "test warning message"); + handle_warning(&mut chat, "test warning message"); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 2, "expected both warning history cells"); +} + #[tokio::test] async fn status_line_invalid_items_warn_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1822,8 +1891,7 @@ async fn session_configured_clears_goal_status_footer() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }); diff --git a/codex-rs/tui/src/chatwidget/warnings.rs b/codex-rs/tui/src/chatwidget/warnings.rs new file mode 100644 index 0000000000..ef9660dcff --- /dev/null +++ b/codex-rs/tui/src/chatwidget/warnings.rs @@ -0,0 +1,23 @@ +use std::collections::HashSet; + +const FALLBACK_MODEL_METADATA_WARNING_PREFIX: &str = "Model metadata for `"; +const FALLBACK_MODEL_METADATA_WARNING_SUFFIX: &str = + "` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."; + +#[derive(Default)] +pub(super) struct WarningDisplayState { + fallback_model_metadata_slugs: HashSet, +} + +impl WarningDisplayState { + pub(super) fn should_display(&mut self, message: &str) -> bool { + fallback_model_metadata_warning_slug(message) + .is_none_or(|slug| self.fallback_model_metadata_slugs.insert(slug.to_string())) + } +} + +fn fallback_model_metadata_warning_slug(message: &str) -> Option<&str> { + message + .strip_prefix(FALLBACK_MODEL_METADATA_WARNING_PREFIX)? + .strip_suffix(FALLBACK_MODEL_METADATA_WARNING_SUFFIX) +} diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 038dc0b285..1ad369d24b 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -3,11 +3,12 @@ //! This module decides *how* to get text onto the user's clipboard based on the //! current environment. The selection order is: //! -//! 1. **SSH session** (`SSH_TTY` / `SSH_CONNECTION` set): use OSC 52 exclusively, -//! because the native clipboard belongs to the remote machine. +//! 1. **SSH session** (`SSH_TTY` / `SSH_CONNECTION` set): use tmux clipboard +//! integration when available, otherwise OSC 52, because the native clipboard +//! belongs to the remote machine. //! 2. **Local session**: try `arboard` (native clipboard) first. On WSL, fall back //! to the Windows clipboard through PowerShell if `arboard` fails. Finally, fall -//! back to OSC 52 if no native/WSL clipboard path succeeds. +//! back to terminal-mediated copy if no native/WSL clipboard path succeeds. //! //! On Linux, X11 and some Wayland compositors require the process that wrote the //! clipboard to keep its handle open. `ClipboardLease` wraps the `arboard::Clipboard` @@ -29,17 +30,22 @@ static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock> = /// Copy text to the system clipboard. /// -/// Over SSH, uses OSC 52 so the text reaches the *local* terminal emulator's -/// clipboard rather than a remote X11/Wayland clipboard that the user cannot -/// access. On a local session, tries `arboard` (native clipboard) first and -/// falls back to WSL PowerShell, then OSC 52, if needed. +/// Over SSH, uses terminal-mediated copy so the text reaches the *local* +/// terminal emulator's clipboard rather than a remote X11/Wayland clipboard +/// that the user cannot access. On a local session, tries `arboard` (native +/// clipboard) first and falls back to WSL PowerShell, then terminal-mediated +/// copy, if needed. /// /// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others. pub(crate) fn copy_to_clipboard(text: &str) -> Result, String> { copy_to_clipboard_with( text, - is_ssh_session(), - is_wsl_session(), + CopyEnvironment { + ssh_session: is_ssh_session(), + wsl_session: is_wsl_session(), + tmux_session: is_tmux_session(), + }, + tmux_clipboard_copy, osc52_copy, arboard_copy, wsl_clipboard_copy, @@ -78,28 +84,45 @@ impl ClipboardLease { /// Core copy logic with injected backends, enabling deterministic unit tests /// without touching real clipboards or terminal I/O. -fn copy_to_clipboard_with( - text: &str, +#[derive(Clone, Copy)] +struct CopyEnvironment { ssh_session: bool, wsl_session: bool, + tmux_session: bool, +} + +fn copy_to_clipboard_with( + text: &str, + environment: CopyEnvironment, + tmux_copy_fn: impl Fn(&str) -> Result<(), String>, osc52_copy_fn: impl Fn(&str) -> Result<(), String>, arboard_copy_fn: impl Fn(&str) -> Result, String>, wsl_copy_fn: impl Fn(&str) -> Result<(), String>, ) -> Result, String> { - if ssh_session { + if environment.ssh_session { // Over SSH the native clipboard writes to the remote machine which is - // useless. Use OSC 52, which travels through the SSH tunnel to the - // local terminal emulator. - return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| { - tracing::warn!("OSC 52 clipboard copy failed over SSH: {osc_err}"); - format!("OSC 52 clipboard copy failed over SSH: {osc_err}") + // useless. Terminal-mediated copy reaches the local terminal emulator. + return terminal_clipboard_copy_with( + text, + environment.tmux_session, + &tmux_copy_fn, + &osc52_copy_fn, + ) + .map(|()| None) + .map_err(|terminal_err| { + tracing::warn!("terminal clipboard copy failed over SSH: {terminal_err}"); + if environment.tmux_session { + format!("terminal clipboard copy failed over SSH: {terminal_err}") + } else { + format!("OSC 52 clipboard copy failed over SSH: {terminal_err}") + } }); } match arboard_copy_fn(text) { Ok(lease) => Ok(lease), Err(native_err) => { - if wsl_session { + if environment.wsl_session { tracing::warn!( "native clipboard copy failed: {native_err}, falling back to WSL PowerShell" ); @@ -107,29 +130,82 @@ fn copy_to_clipboard_with( Ok(()) => return Ok(None), Err(wsl_err) => { tracing::warn!( - "WSL PowerShell clipboard copy failed: {wsl_err}, falling back to OSC 52" + "WSL PowerShell clipboard copy failed: {wsl_err}, falling back to terminal clipboard" ); - return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| { - format!( - "native clipboard: {native_err}; WSL fallback: {wsl_err}; OSC 52 fallback: {osc_err}" - ) + return terminal_clipboard_copy_with( + text, + environment.tmux_session, + &tmux_copy_fn, + &osc52_copy_fn, + ) + .map(|()| None) + .map_err(|terminal_err| { + if environment.tmux_session { + format!( + "native clipboard: {native_err}; WSL fallback: {wsl_err}; terminal fallback: {terminal_err}" + ) + } else { + format!( + "native clipboard: {native_err}; WSL fallback: {wsl_err}; OSC 52 fallback: {terminal_err}" + ) + } }); } } } - tracing::warn!("native clipboard copy failed: {native_err}, falling back to OSC 52"); - osc52_copy_fn(text).map(|()| None).map_err(|osc_err| { - format!("native clipboard: {native_err}; OSC 52 fallback: {osc_err}") + tracing::warn!( + "native clipboard copy failed: {native_err}, falling back to terminal clipboard" + ); + terminal_clipboard_copy_with( + text, + environment.tmux_session, + &tmux_copy_fn, + &osc52_copy_fn, + ) + .map(|()| None) + .map_err(|terminal_err| { + if environment.tmux_session { + format!("native clipboard: {native_err}; terminal fallback: {terminal_err}") + } else { + format!("native clipboard: {native_err}; OSC 52 fallback: {terminal_err}") + } }) } } } +/// Copy through the active terminal, preferring tmux's native clipboard path. +fn terminal_clipboard_copy_with( + text: &str, + tmux_session: bool, + tmux_copy_fn: &impl Fn(&str) -> Result<(), String>, + osc52_copy_fn: &impl Fn(&str) -> Result<(), String>, +) -> Result<(), String> { + if tmux_session { + match tmux_copy_fn(text) { + Ok(()) => return Ok(()), + Err(tmux_err) => { + tracing::warn!("tmux clipboard copy failed: {tmux_err}, falling back to OSC 52"); + return osc52_copy_fn(text).map_err(|osc_err| { + format!("tmux clipboard: {tmux_err}; OSC 52 fallback: {osc_err}") + }); + } + } + } + + osc52_copy_fn(text) +} + /// Detect whether the current process is running inside an SSH session. fn is_ssh_session() -> bool { std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some() } +/// Detect whether the current process is running inside tmux. +fn is_tmux_session() -> bool { + std::env::var_os("TMUX").is_some() || std::env::var_os("TMUX_PANE").is_some() +} + #[cfg(target_os = "linux")] fn is_wsl_session() -> bool { crate::clipboard_paste::is_probably_wsl() @@ -234,6 +310,93 @@ fn wsl_clipboard_copy(_text: &str) -> Result<(), String> { Err("WSL clipboard fallback unavailable on this platform".to_string()) } +/// Copy text through tmux's native clipboard integration. +/// +/// `load-buffer -w -` lets tmux read the text from stdin, keep a matching tmux +/// paste buffer, and forward the contents to the outer terminal clipboard when +/// possible without relying on DCS passthrough. +fn tmux_clipboard_copy(text: &str) -> Result<(), String> { + tmux_clipboard_copy_ready( + || tmux_command_output(["show-options", "-gv", "set-clipboard"]), + || tmux_command_output(["info"]), + )?; + + let mut child = std::process::Command::new("tmux") + .args(["load-buffer", "-w", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn tmux: {e}"))?; + + let Some(mut stdin) = child.stdin.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("failed to open tmux stdin".to_string()); + }; + + if let Err(err) = stdin.write_all(text.as_bytes()) { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("failed to write to tmux: {err}")); + } + + drop(stdin); + + let output = child + .wait_with_output() + .map_err(|e| format!("failed to wait for tmux: {e}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + let status = output.status; + Err(format!("tmux exited with status {status}")) + } else { + Err(format!("tmux failed: {stderr}")) + } + } +} + +/// Verify that tmux is configured to forward clipboard writes to the outer terminal. +fn tmux_clipboard_copy_ready( + set_clipboard_fn: impl FnOnce() -> Result, + tmux_info_fn: impl FnOnce() -> Result, +) -> Result<(), String> { + let set_clipboard = set_clipboard_fn()?; + if set_clipboard.trim() == "off" { + return Err("tmux clipboard forwarding is disabled".to_string()); + } + + let tmux_info = tmux_info_fn()?; + if tmux_info.lines().any(|line| line.contains("Ms: [missing]")) { + return Err("tmux clipboard forwarding is unavailable: missing Ms capability".to_string()); + } + + Ok(()) +} + +fn tmux_command_output(args: [&str; N]) -> Result { + let output = std::process::Command::new("tmux") + .args(args) + .output() + .map_err(|e| format!("failed to spawn tmux: {e}"))?; + + if output.status.success() { + String::from_utf8(output.stdout).map_err(|e| format!("tmux output was not UTF-8: {e}")) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + let status = output.status; + Err(format!("tmux exited with status {status}")) + } else { + Err(format!("tmux failed: {stderr}")) + } + } +} + /// RAII guard that redirects stderr (fd 2) to `/dev/null` on creation and /// restores the original fd on drop. #[cfg(target_os = "macos")] @@ -342,11 +505,50 @@ mod tests { use pretty_assertions::assert_eq; use std::cell::Cell; + use super::CopyEnvironment; use super::OSC52_MAX_RAW_BYTES; use super::copy_to_clipboard_with; use super::osc52_sequence; + use super::tmux_clipboard_copy_ready; use super::write_osc52_to_writer; + fn remote_environment() -> CopyEnvironment { + CopyEnvironment { + ssh_session: true, + wsl_session: true, + tmux_session: false, + } + } + + fn remote_tmux_environment() -> CopyEnvironment { + CopyEnvironment { + tmux_session: true, + ..remote_environment() + } + } + + fn local_environment() -> CopyEnvironment { + CopyEnvironment { + ssh_session: false, + wsl_session: false, + tmux_session: false, + } + } + + fn local_wsl_environment() -> CopyEnvironment { + CopyEnvironment { + wsl_session: true, + ..local_environment() + } + } + + fn local_tmux_environment() -> CopyEnvironment { + CopyEnvironment { + tmux_session: true, + ..local_environment() + } + } + #[test] fn osc52_encoding_roundtrips() { use base64::Engine; @@ -391,13 +593,17 @@ mod tests { #[test] fn ssh_uses_osc52_and_skips_native_on_success() { + let tmux_calls = Cell::new(0_u8); let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ true, - /*wsl_session*/ true, + remote_environment(), + |_| { + tmux_calls.set(tmux_calls.get() + 1); + Ok(()) + }, |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -413,6 +619,7 @@ mod tests { ); assert!(matches!(result, Ok(None))); + assert_eq!(tmux_calls.get(), 0); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 0); assert_eq!(wsl_calls.get(), 0); @@ -420,13 +627,17 @@ mod tests { #[test] fn ssh_returns_osc52_error_and_skips_native() { + let tmux_calls = Cell::new(0_u8); let osc_calls = Cell::new(0_u8); let native_calls = Cell::new(0_u8); let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ true, - /*wsl_session*/ true, + remote_environment(), + |_| { + tmux_calls.set(tmux_calls.get() + 1); + Ok(()) + }, |_| { osc_calls.set(osc_calls.get() + 1); Err("blocked".into()) @@ -445,11 +656,136 @@ mod tests { panic!("expected OSC 52 error"); }; assert_eq!(error, "OSC 52 clipboard copy failed over SSH: blocked"); + assert_eq!(tmux_calls.get(), 0); assert_eq!(osc_calls.get(), 1); assert_eq!(native_calls.get(), 0); assert_eq!(wsl_calls.get(), 0); } + #[test] + fn ssh_inside_tmux_prefers_tmux_clipboard() { + let tmux_calls = Cell::new(0_u8); + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + remote_tmux_environment(), + |_| { + tmux_calls.set(tmux_calls.get() + 1); + Ok(()) + }, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Ok(None) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, + ); + + assert!(matches!(result, Ok(None))); + assert_eq!(tmux_calls.get(), 1); + assert_eq!(osc_calls.get(), 0); + assert_eq!(native_calls.get(), 0); + assert_eq!(wsl_calls.get(), 0); + } + + #[test] + fn ssh_inside_tmux_falls_back_to_osc52_when_tmux_copy_fails() { + let tmux_calls = Cell::new(0_u8); + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + remote_tmux_environment(), + |_| { + tmux_calls.set(tmux_calls.get() + 1); + Err("tmux unavailable".into()) + }, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Ok(None) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, + ); + + assert!(matches!(result, Ok(None))); + assert_eq!(tmux_calls.get(), 1); + assert_eq!(osc_calls.get(), 1); + assert_eq!(native_calls.get(), 0); + assert_eq!(wsl_calls.get(), 0); + } + + #[test] + fn ssh_inside_tmux_reports_tmux_and_osc52_errors_when_both_fail() { + let result = copy_to_clipboard_with( + "hello", + remote_tmux_environment(), + |_| Err("tmux unavailable".into()), + |_| Err("osc blocked".into()), + |_| Ok(None), + |_| Ok(()), + ); + + let Err(error) = result else { + panic!("expected tmux and OSC 52 errors"); + }; + assert_eq!( + error, + "terminal clipboard copy failed over SSH: tmux clipboard: tmux unavailable; OSC 52 fallback: osc blocked" + ); + } + + #[test] + fn tmux_clipboard_copy_ready_accepts_forwarding_configuration() { + let result = tmux_clipboard_copy_ready( + || Ok("external\n".to_string()), + || Ok("193: Ms: (string) \\033]52;%p1%s;%p2%s\\a\n".to_string()), + ); + + assert_eq!(result, Ok(())); + } + + #[test] + fn tmux_clipboard_copy_ready_rejects_disabled_forwarding() { + let result = tmux_clipboard_copy_ready( + || Ok("off\n".to_string()), + || panic!("tmux info should not be queried when forwarding is disabled"), + ); + + assert_eq!( + result, + Err("tmux clipboard forwarding is disabled".to_string()) + ); + } + + #[test] + fn tmux_clipboard_copy_ready_rejects_missing_ms_capability() { + let result = tmux_clipboard_copy_ready( + || Ok("external\n".to_string()), + || Ok("193: Ms: [missing]\n".to_string()), + ); + + assert_eq!( + result, + Err("tmux clipboard forwarding is unavailable: missing Ms capability".to_string()) + ); + } + #[test] fn local_uses_native_clipboard_first() { let osc_calls = Cell::new(0_u8); @@ -457,8 +793,8 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ false, - /*wsl_session*/ true, + local_wsl_environment(), + |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -486,8 +822,8 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ false, - /*wsl_session*/ false, + local_environment(), + |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -508,6 +844,40 @@ mod tests { assert_eq!(wsl_calls.get(), 0); } + #[test] + fn local_tmux_fallback_prefers_tmux_when_native_fails() { + let tmux_calls = Cell::new(0_u8); + let osc_calls = Cell::new(0_u8); + let native_calls = Cell::new(0_u8); + let wsl_calls = Cell::new(0_u8); + let result = copy_to_clipboard_with( + "hello", + local_tmux_environment(), + |_| { + tmux_calls.set(tmux_calls.get() + 1); + Ok(()) + }, + |_| { + osc_calls.set(osc_calls.get() + 1); + Ok(()) + }, + |_| { + native_calls.set(native_calls.get() + 1); + Err("native unavailable".into()) + }, + |_| { + wsl_calls.set(wsl_calls.get() + 1); + Ok(()) + }, + ); + + assert!(matches!(result, Ok(None))); + assert_eq!(tmux_calls.get(), 1); + assert_eq!(osc_calls.get(), 0); + assert_eq!(native_calls.get(), 1); + assert_eq!(wsl_calls.get(), 0); + } + #[test] fn local_wsl_native_failure_uses_powershell_and_skips_osc52_on_success() { let osc_calls = Cell::new(0_u8); @@ -515,8 +885,8 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ false, - /*wsl_session*/ true, + local_wsl_environment(), + |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -544,8 +914,8 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ false, - /*wsl_session*/ true, + local_wsl_environment(), + |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Ok(()) @@ -573,8 +943,8 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ false, - /*wsl_session*/ false, + local_environment(), + |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Err("osc blocked".into()) @@ -608,8 +978,8 @@ mod tests { let wsl_calls = Cell::new(0_u8); let result = copy_to_clipboard_with( "hello", - /*ssh_session*/ false, - /*wsl_session*/ true, + local_wsl_environment(), + |_| Ok(()), |_| { osc_calls.set(osc_calls.get() + 1); Err("osc blocked".into()) diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index f780e3d3e3..882683ad28 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -5,6 +5,7 @@ use super::model::ExecCall; use super::model::ExecCell; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell::HistoryCell; +use crate::history_cell::plain_lines; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -243,6 +244,10 @@ impl HistoryCell for ExecCell { } lines } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.transcript_lines(u16::MAX)) + } } impl ExecCell { diff --git a/codex-rs/tui/src/get_git_diff.rs b/codex-rs/tui/src/get_git_diff.rs index 78ab53d92f..a7b4b668fb 100644 --- a/codex-rs/tui/src/get_git_diff.rs +++ b/codex-rs/tui/src/get_git_diff.rs @@ -5,25 +5,32 @@ //! untracked files. When the current directory is not inside a Git //! repository, the function returns `Ok((false, String::new()))`. -use std::io; use std::path::Path; -use std::process::Stdio; -use tokio::process::Command; +use std::time::Duration; + +use crate::workspace_command::WorkspaceCommand; +use crate::workspace_command::WorkspaceCommandExecutor; +use crate::workspace_command::WorkspaceCommandOutput; + +const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); /// Return value of [`get_git_diff`]. /// /// * `bool` – Whether the current working directory is inside a Git repo. /// * `String` – The concatenated diff (may be empty). -pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { +pub(crate) async fn get_git_diff( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Result<(bool, String), String> { // First check if we are inside a Git repository. - if !inside_git_repo().await? { + if !inside_git_repo(runner, cwd).await? { return Ok((false, String::new())); } // Run tracked diff and untracked file listing in parallel. let (tracked_diff_res, untracked_output_res) = tokio::join!( - run_git_capture_diff(&["diff", "--color"]), - run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"]), + run_git_capture_diff(runner, cwd, &["diff", "--color"]), + run_git_capture_stdout(runner, cwd, &["ls-files", "--others", "--exclude-standard"]), ); let tracked_diff = tracked_diff_res?; let untracked_output = untracked_output_res?; @@ -35,27 +42,15 @@ pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { Path::new("/dev/null") }; - let null_path = null_device.to_str().unwrap_or("/dev/null").to_string(); - let mut join_set: tokio::task::JoinSet> = tokio::task::JoinSet::new(); + let null_path = null_device.to_str().unwrap_or("/dev/null"); for file in untracked_output .split('\n') .map(str::trim) .filter(|s| !s.is_empty()) { - let null_path = null_path.clone(); - let file = file.to_string(); - join_set.spawn(async move { - let args = ["diff", "--color", "--no-index", "--", &null_path, &file]; - run_git_capture_diff(&args).await - }); - } - while let Some(res) = join_set.join_next().await { - match res { - Ok(Ok(diff)) => untracked_diff.push_str(&diff), - Ok(Err(err)) if err.kind() == io::ErrorKind::NotFound => {} - Ok(Err(err)) => return Err(err), - Err(_) => {} - } + let args = ["diff", "--color", "--no-index", "--", null_path, file]; + let diff = run_git_capture_diff(runner, cwd, &args).await?; + untracked_diff.push_str(&diff); } Ok((true, format!("{tracked_diff}{untracked_diff}"))) @@ -63,57 +58,282 @@ pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { /// Helper that executes `git` with the given `args` and returns `stdout` as a /// UTF-8 string. Any non-zero exit status is considered an *error*. -async fn run_git_capture_stdout(args: &[&str]) -> io::Result { - let output = Command::new("git") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +async fn run_git_capture_stdout( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let output = run_git_command(runner, cwd, args).await?; + if output.success() { + Ok(output.stdout) } else { - Err(io::Error::other(format!( + Err(format!( "git {:?} failed with status {}", - args, output.status - ))) + args, output.exit_code + )) } } /// Like [`run_git_capture_stdout`] but treats exit status 1 as success and /// returns stdout. Git returns 1 for diffs when differences are present. -async fn run_git_capture_diff(args: &[&str]) -> io::Result { - let output = Command::new("git") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await?; - - if output.status.success() || output.status.code() == Some(1) { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +async fn run_git_capture_diff( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let output = run_git_command(runner, cwd, args).await?; + if output.success() || output.exit_code == 1 { + Ok(output.stdout) } else { - Err(io::Error::other(format!( + Err(format!( "git {:?} failed with status {}", - args, output.status - ))) + args, output.exit_code + )) } } /// Determine if the current directory is inside a Git repository. -async fn inside_git_repo() -> io::Result { - let status = Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; +async fn inside_git_repo( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Result { + let output = run_git_command(runner, cwd, &["rev-parse", "--is-inside-work-tree"]).await?; + Ok(output.success()) +} - match status { - Ok(s) if s.success() => Ok(true), - Ok(_) => Ok(false), - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed - Err(e) => Err(e), +async fn run_git_command( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push("git".to_string()); + argv.extend(args.iter().map(|arg| (*arg).to_string())); + runner + .run( + WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .timeout(DIFF_COMMAND_TIMEOUT) + .disable_output_cap(), + ) + .await + .map_err(|err| err.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace_command::WorkspaceCommandError; + use pretty_assertions::assert_eq; + use std::collections::VecDeque; + use std::future::Future; + use std::path::PathBuf; + use std::pin::Pin; + use std::sync::Mutex; + + #[tokio::test] + async fn get_git_diff_returns_not_git_for_non_git_cwd() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 128, + "", + )]); + + let result = get_git_diff(&runner, &cwd).await; + + assert_eq!(result, Ok((false, String::new()))); + assert_commands( + &runner.commands(), + &[&["git", "rev-parse", "--is-inside-work-tree"]], + &cwd, + ); + } + + #[tokio::test] + async fn get_git_diff_concatenates_tracked_and_untracked_diffs() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 0, + "true\n", + ), + response( + &["git", "diff", "--color"], + /*exit_code*/ 1, + "tracked\n", + ), + response( + &["git", "ls-files", "--others", "--exclude-standard"], + /*exit_code*/ 0, + "new.txt\n", + ), + response( + &[ + "git", + "diff", + "--color", + "--no-index", + "--", + null_device(), + "new.txt", + ], + /*exit_code*/ 1, + "untracked\n", + ), + ]); + + let result = get_git_diff(&runner, &cwd).await; + + assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string()))); + assert_commands( + &runner.commands(), + &[ + &["git", "rev-parse", "--is-inside-work-tree"], + &["git", "diff", "--color"], + &["git", "ls-files", "--others", "--exclude-standard"], + &[ + "git", + "diff", + "--color", + "--no-index", + "--", + null_device(), + "new.txt", + ], + ], + &cwd, + ); + } + + #[tokio::test] + async fn get_git_diff_accepts_diff_exit_code_one() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 0, + "true\n", + ), + response( + &["git", "diff", "--color"], + /*exit_code*/ 1, + "tracked\n", + ), + response( + &["git", "ls-files", "--others", "--exclude-standard"], + /*exit_code*/ 0, + "", + ), + ]); + + let result = get_git_diff(&runner, &cwd).await; + + assert_eq!(result, Ok((true, "tracked\n".to_string()))); + } + + #[tokio::test] + async fn get_git_diff_rejects_unexpected_git_diff_status() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 0, + "true\n", + ), + response(&["git", "diff", "--color"], /*exit_code*/ 2, ""), + response( + &["git", "ls-files", "--others", "--exclude-standard"], + /*exit_code*/ 0, + "", + ), + ]); + + let error = get_git_diff(&runner, &cwd) + .await + .expect_err("unexpected git diff status should fail"); + + assert!( + error.contains("git [\"diff\", \"--color\"] failed with status 2"), + "unexpected error: {error}", + ); + } + + fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse { + FakeResponse { + argv: argv.iter().map(|arg| (*arg).to_string()).collect(), + output: WorkspaceCommandOutput { + exit_code, + stdout: stdout.to_string(), + stderr: String::new(), + }, + } + } + + fn null_device() -> &'static str { + if cfg!(windows) { "NUL" } else { "/dev/null" } + } + + fn assert_commands(commands: &[WorkspaceCommand], expected: &[&[&str]], cwd: &Path) { + let actual: Vec> = commands + .iter() + .map(|command| command.argv.clone()) + .collect(); + let expected: Vec> = expected + .iter() + .map(|argv| argv.iter().map(|arg| (*arg).to_string()).collect()) + .collect(); + assert_eq!(actual, expected); + + for command in commands { + assert_eq!(command.cwd.as_deref(), Some(cwd)); + assert_eq!(command.timeout, DIFF_COMMAND_TIMEOUT); + assert!(command.disable_output_cap); + } + } + + struct FakeResponse { + argv: Vec, + output: WorkspaceCommandOutput, + } + + struct FakeRunner { + responses: Mutex>, + commands: Mutex>, + } + + impl FakeRunner { + fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses.into()), + commands: Mutex::new(Vec::new()), + } + } + + fn commands(&self) -> Vec { + self.commands.lock().expect("commands lock").clone() + } + } + + impl WorkspaceCommandExecutor for FakeRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + Box::pin(async move { + let mut responses = self.responses.lock().expect("responses lock"); + let response = responses.pop_front().expect("missing fake response"); + assert_eq!(command.argv, response.argv); + self.commands.lock().expect("commands lock").push(command); + Ok(response.output) + }) + } } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5ed2cee9ed..256345f67a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -99,12 +99,51 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use url::Url; +const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000; +const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000; + mod hook_cell; pub(crate) use hook_cell::HookCell; pub(crate) use hook_cell::new_active_hook_cell; pub(crate) use hook_cell::new_completed_hook_cell; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistoryRenderMode { + Rich, + Raw, +} + +pub(crate) fn raw_lines_from_source(source: &str) -> Vec> { + if source.is_empty() { + return Vec::new(); + } + + let mut parts = source.split('\n').collect::>(); + if source.ends_with('\n') { + parts.pop(); + } + + parts + .into_iter() + .map(|line| Line::from(line.to_string())) + .collect() +} + +pub(crate) fn plain_lines(lines: impl IntoIterator>) -> Vec> { + lines + .into_iter() + .map(|line| { + let text = line + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::(); + Line::from(text) + }) + .collect() +} + /// A single renderable unit of conversation history. /// /// Each cell produces logical `Line`s and reports how many viewport @@ -118,6 +157,16 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// Returns the logical lines for the main chat viewport. fn display_lines(&self, width: u16) -> Vec>; + /// Returns copy-friendly plain logical lines for raw scrollback mode. + fn raw_lines(&self) -> Vec>; + + fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { + match mode { + HistoryRenderMode::Rich => self.display_lines(width), + HistoryRenderMode::Raw => self.raw_lines(), + } + } + /// Returns the number of viewport rows needed to render this cell. /// /// The default delegates to `Paragraph::line_count` with @@ -126,7 +175,11 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// for lines containing URL-like tokens that are wider than the /// terminal — the logical line count would undercount. fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(self.display_lines(width))) + self.desired_height_for_mode(width, HistoryRenderMode::Rich) + } + + fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 { + Paragraph::new(Text::from(self.display_lines_for_mode(width, mode))) .wrap(Wrap { trim: false }) .line_count(width) .try_into() @@ -391,6 +444,22 @@ impl HistoryCell for UserHistoryCell { lines.push(Line::from("").style(style)); lines } + + fn raw_lines(&self) -> Vec> { + let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); + if !self.remote_image_urls.is_empty() { + if !lines.is_empty() { + lines.push(Line::from("")); + } + lines.extend( + self.remote_image_urls + .iter() + .enumerate() + .map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))), + ); + } + lines + } } #[derive(Debug)] @@ -456,6 +525,14 @@ impl HistoryCell for ReasoningSummaryCell { fn transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } + + fn raw_lines(&self) -> Vec> { + if self.transcript_only { + Vec::new() + } else { + raw_lines_from_source(self.content.trim()) + } + } } #[derive(Debug)] @@ -487,6 +564,10 @@ impl HistoryCell for AgentMessageCell { ) } + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } + fn is_stream_continuation(&self) -> bool { !self.is_first_line } @@ -541,6 +622,10 @@ impl HistoryCell for AgentMarkdownCell { ); prefix_lines(lines, "• ".dim(), " ".into()) } + + fn raw_lines(&self) -> Vec> { + raw_lines_from_source(&self.markdown_source) + } } #[derive(Debug)] @@ -558,6 +643,10 @@ impl HistoryCell for PlainHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } } #[cfg_attr(debug_assertions, allow(dead_code))] @@ -612,6 +701,22 @@ impl HistoryCell for UpdateAvailableHistoryCell { .max(1); with_border_with_inner_width(content.lines, inner_width) } + + fn raw_lines(&self) -> Vec> { + let update_instruction = if let Some(update_action) = self.update_action { + format!("Run {} to update.", update_action.command_str()) + } else { + "See https://github.com/openai/codex for installation options.".to_string() + }; + vec![ + Line::from("Update available!"), + Line::from(format!("{CODEX_CLI_VERSION} -> {}", self.latest_version)), + Line::from(update_instruction), + Line::from(""), + Line::from("See full release notes:"), + Line::from("https://github.com/openai/codex/releases/latest"), + ] + } } #[derive(Debug)] @@ -645,6 +750,10 @@ impl HistoryCell for PrefixedWrappedHistoryCell { .subsequent_indent(self.subsequent_prefix.clone()); adaptive_wrap_lines(&self.text, opts) } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.text.clone().lines) + } } #[derive(Debug)] @@ -706,6 +815,38 @@ impl HistoryCell for UnifiedExecInteractionCell { out.extend(input_wrapped); out } + + fn raw_lines(&self) -> Vec> { + let mut out = Vec::new(); + if self.stdin.is_empty() { + if let Some(command) = self + .command_display + .as_ref() + .filter(|command| !command.is_empty()) + { + out.push(Line::from(format!( + "Waited for background terminal: {command}" + ))); + } else { + out.push(Line::from("Waited for background terminal")); + } + return out; + } + + if let Some(command) = self + .command_display + .as_ref() + .filter(|command| !command.is_empty()) + { + out.push(Line::from(format!( + "Interacted with background terminal: {command}" + ))); + } else { + out.push(Line::from("Interacted with background terminal")); + } + out.extend(raw_lines_from_source(&self.stdin)); + out + } } pub(crate) fn new_unified_exec_interaction( @@ -837,6 +978,10 @@ impl HistoryCell for UnifiedExecProcessesCell { out } + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } + fn desired_height(&self, width: u16) -> u16 { self.display_lines(width).len() as u16 } @@ -1108,6 +1253,14 @@ impl HistoryCell for PatchHistoryCell { fn display_lines(&self, width: u16) -> Vec> { create_diff_summary(&self.changes, &self.cwd, width as usize) } + + fn raw_lines(&self) -> Vec> { + plain_lines(create_diff_summary( + &self.changes, + &self.cwd, + RAW_DIFF_SUMMARY_WIDTH, + )) + } } #[derive(Debug)] @@ -1118,6 +1271,10 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self, _width: u16) -> Vec> { vec!["tool result (image output)".into()] } + + fn raw_lines(&self) -> Vec> { + vec![Line::from("tool result (image output)")] + } } pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value @@ -1228,6 +1385,10 @@ impl HistoryCell for TooltipHistoryCell { prefix_lines(lines, indent.into(), indent.into()) } + + fn raw_lines(&self) -> Vec> { + vec![Line::from(format!("Tip: {}", self.tip))] + } } #[derive(Debug)] @@ -1245,6 +1406,10 @@ impl HistoryCell for SessionInfoCell { fn transcript_lines(&self, width: u16) -> Vec> { self.0.transcript_lines(width) } + + fn raw_lines(&self) -> Vec> { + self.0.raw_lines() + } } pub(crate) fn new_session_info( @@ -1539,6 +1704,27 @@ impl HistoryCell for SessionHeaderHistoryCell { with_border(lines) } + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![ + Line::from(format!("OpenAI Codex (v{})", self.version)), + Line::from(format!( + "model: {}{}", + self.model, + self.reasoning_label() + .map(|reasoning| format!(" {reasoning}")) + .unwrap_or_default() + )), + Line::from(format!( + "directory: {}", + self.format_directory(/*max_width*/ None) + )), + ]; + if self.yolo_mode { + lines.push(Line::from("permissions: YOLO mode")); + } + lines + } } #[derive(Debug)] @@ -1568,6 +1754,22 @@ impl HistoryCell for CompositeHistoryCell { } out } + + fn raw_lines(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.raw_lines(); + if !lines.is_empty() { + if !first { + out.push(Line::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } } #[derive(Debug)] @@ -1759,6 +1961,32 @@ impl HistoryCell for McpToolCallCell { lines } + fn raw_lines(&self) -> Vec> { + let header_text = if self.success().is_some() { + "Called" + } else { + "Calling" + }; + let mut lines = vec![Line::from(format!( + "{header_text} {}", + format_mcp_invocation(self.invocation.clone()) + ))]; + + if let Some(result) = &self.result { + match result { + Ok(codex_protocol::mcp::CallToolResult { content, .. }) => { + for block in content { + let text = Self::render_content_block(block, RAW_TOOL_OUTPUT_WIDTH); + lines.extend(raw_lines_from_source(&text)); + } + } + Err(err) => lines.push(Line::from(format!("Error: {err}"))), + } + } + + lines + } + fn transcript_animation_tick(&self) -> Option { if !self.animations_enabled || self.result.is_some() { return None; @@ -1881,6 +2109,16 @@ impl HistoryCell for WebSearchCell { }; PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width) } + + fn raw_lines(&self) -> Vec> { + let header = web_search_header(self.completed); + let detail = web_search_detail(self.action.as_ref(), &self.query); + if detail.is_empty() { + vec![Line::from(header)] + } else { + vec![Line::from(format!("{header} {detail}"))] + } + } } pub(crate) fn new_active_web_search_call( @@ -2016,6 +2254,16 @@ impl HistoryCell for CyberPolicyNoticeCell { lines } + + fn raw_lines(&self) -> Vec> { + vec![ + Line::from("This chat was flagged for possible cybersecurity risk"), + Line::from( + "If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program.", + ), + Line::from(TRUSTED_ACCESS_FOR_CYBER_URL), + ] + } } #[derive(Debug)] @@ -2046,6 +2294,14 @@ impl HistoryCell for DeprecationNoticeCell { lines } + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![Line::from(self.summary.clone())]; + if let Some(details) = &self.details { + lines.extend(raw_lines_from_source(details)); + } + lines + } } /// Render a summary of configured MCP servers from the current `Config`. @@ -2494,6 +2750,10 @@ impl HistoryCell for McpInventoryLoadingCell { ] } + fn raw_lines(&self) -> Vec> { + vec![Line::from("Loading MCP inventory...")] + } + fn transcript_animation_tick(&self) -> Option { if !self.animations_enabled { return None; @@ -2612,6 +2872,48 @@ impl HistoryCell for RequestUserInputResultCell { lines } + + fn raw_lines(&self) -> Vec> { + let total = self.questions.len(); + let answered = self + .questions + .iter() + .filter(|question| { + self.answers + .get(&question.id) + .is_some_and(|answer| !answer.answers.is_empty()) + }) + .count(); + let mut lines = vec![Line::from(format!("Questions {answered}/{total} answered"))]; + if self.interrupted { + lines.push(Line::from("(interrupted)")); + } + for question in &self.questions { + lines.push(Line::from(question.question.clone())); + if let Some(answer) = self + .answers + .get(&question.id) + .filter(|answer| !answer.answers.is_empty()) + { + if question.is_secret { + lines.push(Line::from("answer: ******")); + } else { + let (options, note) = split_request_user_input_answer(answer); + lines.extend( + options + .into_iter() + .map(|option| Line::from(format!("answer: {option}"))), + ); + if let Some(note) = note { + lines.push(Line::from(format!("note: {note}"))); + } + } + } else { + lines.push(Line::from("(unanswered)")); + } + } + lines + } } /// Wrap a plain string with textwrap and prefix each line, while applying a style to the content. @@ -2728,6 +3030,10 @@ impl HistoryCell for ProposedPlanCell { lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style))); lines } + + fn raw_lines(&self) -> Vec> { + raw_lines_from_source(&self.plan_markdown) + } } impl HistoryCell for ProposedPlanStreamCell { @@ -2735,6 +3041,10 @@ impl HistoryCell for ProposedPlanStreamCell { self.lines.clone() } + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } + fn is_stream_continuation(&self) -> bool { self.is_stream_continuation } @@ -2798,6 +3108,26 @@ impl HistoryCell for PlanUpdateCell { lines } + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![Line::from("Updated Plan")]; + if let Some(explanation) = self + .explanation + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + lines.extend(raw_lines_from_source(explanation)); + } + if self.plan.is_empty() { + lines.push(Line::from("(no steps provided)")); + } else { + for PlanItemArg { step, status } in &self.plan { + lines.push(Line::from(format!("{status:?}: {step}"))); + } + } + lines + } } /// Create a new `PendingPatch` cell that lists the file‑level summary of @@ -2959,6 +3289,25 @@ impl HistoryCell for FinalMessageSeparator { .dim(), ] } + + fn raw_lines(&self) -> Vec> { + let mut label_parts = Vec::new(); + if let Some(elapsed_seconds) = self + .elapsed_seconds + .filter(|seconds| *seconds > 60) + .map(super::status_indicator_widget::fmt_elapsed_compact) + { + label_parts.push(format!("Worked for {elapsed_seconds}")); + } + if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) { + label_parts.push(metrics_label); + } + if label_parts.is_empty() { + Vec::new() + } else { + vec![Line::from(label_parts.join(" • "))] + } + } } pub(crate) fn runtime_metrics_label(summary: RuntimeMetricsSummary) -> Option { @@ -3225,6 +3574,15 @@ mod tests { render_lines(&cell.transcript_lines(u16::MAX)) } + fn assert_unstyled_lines(lines: &[Line<'static>]) { + for line in lines { + assert_eq!(line.style, Style::default()); + for span in &line.spans { + assert_eq!(span.style, Style::default()); + } + } + } + fn image_block(data: &str) -> serde_json::Value { serde_json::to_value(Content::image(data.to_string(), "image/png")) .expect("image content should serialize") @@ -3253,6 +3611,185 @@ mod tests { .expect("resource link content should serialize") } + #[test] + fn raw_lines_from_source_preserves_explicit_blank_lines() { + let lines = raw_lines_from_source("alpha\n\nbeta\n"); + + assert_eq!( + render_lines(&lines), + vec!["alpha".to_string(), String::new(), "beta".to_string()] + ); + assert_unstyled_lines(&lines); + } + + #[test] + fn raw_lines_from_source_preserves_trailing_blank_but_not_trailing_newline() { + assert_eq!( + render_lines(&raw_lines_from_source("alpha\n\n")), + vec!["alpha".to_string(), String::new()] + ); + assert_eq!(raw_lines_from_source(""), Vec::>::new()); + } + + #[test] + fn source_backed_cells_render_raw_source_without_prefix_or_style() { + let user = new_user_prompt( + "hello\n\nworld\n".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + let assistant = AgentMarkdownCell::new( + "- item\n\n| A | B |\n| - | - |\n| x | y |\n".to_string(), + &test_cwd(), + ); + let reasoning = ReasoningSummaryCell::new( + "thinking".to_string(), + "first thought\n\nsecond thought".to_string(), + &test_cwd(), + /*transcript_only*/ false, + ); + let plan = new_proposed_plan( + "1. Inspect\n\n```sh\ncargo test\n```".to_string(), + &test_cwd(), + ); + + let user_lines = user.raw_lines(); + assert_eq!( + render_lines(&user_lines), + vec!["hello".to_string(), String::new(), "world".to_string()] + ); + assert_unstyled_lines(&user_lines); + + let assistant_lines = assistant.raw_lines(); + assert_eq!( + render_lines(&assistant_lines), + vec![ + "- item".to_string(), + String::new(), + "| A | B |".to_string(), + "| - | - |".to_string(), + "| x | y |".to_string(), + ] + ); + assert_unstyled_lines(&assistant_lines); + + let reasoning_lines = reasoning.raw_lines(); + assert_eq!( + render_lines(&reasoning_lines), + vec![ + "first thought".to_string(), + String::new(), + "second thought".to_string(), + ] + ); + assert_unstyled_lines(&reasoning_lines); + + let plan_lines = plan.raw_lines(); + assert_eq!( + render_lines(&plan_lines), + vec![ + "1. Inspect".to_string(), + String::new(), + "```sh".to_string(), + "cargo test".to_string(), + "```".to_string(), + ] + ); + assert_unstyled_lines(&plan_lines); + } + + #[test] + fn structured_tool_cell_renders_raw_plain_text_without_prefix_or_style() { + let invocation = McpInvocation { + server: "search".into(), + tool: "find_docs".into(), + arguments: Some(json!({"query": "raw mode"})), + }; + let result = CallToolResult { + content: vec![text_block("alpha\nbeta")], + is_error: None, + structured_content: None, + meta: None, + }; + let mut cell = new_active_mcp_tool_call( + "call-raw".to_string(), + invocation, + /*animations_enabled*/ false, + ); + assert!( + cell.complete(Duration::from_millis(1), Ok(result)) + .is_none() + ); + + let lines = cell.raw_lines(); + let rendered = render_lines(&lines); + assert!(rendered[0].starts_with("Called search.find_docs(")); + assert_eq!(rendered[1..], ["alpha".to_string(), "beta".to_string()]); + assert_unstyled_lines(&lines); + } + + #[test] + fn raw_mode_toggle_transcript_snapshot() { + let mut tool_cell = new_active_mcp_tool_call( + "call-snapshot".to_string(), + McpInvocation { + server: "workspace".to_string(), + tool: "inspect".to_string(), + arguments: Some(json!({"path": "README.md"})), + }, + /*animations_enabled*/ false, + ); + assert!( + tool_cell + .complete( + Duration::from_millis(5), + Ok(CallToolResult { + content: vec![text_block("structured output\nsecond line")], + is_error: None, + structured_content: None, + meta: None, + }), + ) + .is_none() + ); + let cells: Vec> = vec![ + Box::new(new_user_prompt( + "Please format this\nfor copying".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + )), + Box::new(AgentMarkdownCell::new( + "- first item\n- second item\n\n| Col | Value |\n| --- | --- |\n| code | `x = 1` |\n\n```text\ncopy me\n```".to_string(), + &test_cwd(), + )), + Box::new(tool_cell), + ]; + + let render = |mode| { + cells + .iter() + .flat_map(|cell| cell.display_lines_for_mode(/*width*/ 40, mode)) + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }) + .collect::>() + .join("\n") + }; + let rendered = format!( + "rich before:\n{}\n\nraw on:\n{}\n\nrich after:\n{}", + render(HistoryRenderMode::Rich), + render(HistoryRenderMode::Raw), + render(HistoryRenderMode::Rich) + ); + + insta::assert_snapshot!("raw_mode_toggle_transcript", rendered); + } + #[test] fn image_generation_call_renders_saved_path() { let saved_path = test_path_buf("/tmp/generated-image.png").abs(); @@ -3293,8 +3830,7 @@ mod tests { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index ec5f1ca18e..3b87eff6d3 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -11,6 +11,7 @@ //! first drawn. //! 4. Completed runs only persist when they have output or a non-success status. use super::HistoryCell; +use super::plain_lines; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -340,6 +341,10 @@ impl HistoryCell for HookCell { self.display_lines(width) } + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } + /// Produces a coarse cache key for transcript overlays while hook animations are active. fn transcript_animation_tick(&self) -> Option { if !self.animations_enabled { diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 4f3ea981bd..2543b56bac 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -57,6 +57,12 @@ impl InsertHistoryMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryLineWrapPolicy { + PreWrap, + Terminal, +} + /// Insert `lines` above the viewport using the terminal's backend writer /// (avoids direct stdout references). pub fn insert_history_lines( @@ -83,6 +89,23 @@ pub fn insert_history_lines_with_mode( lines: Vec, mode: InsertHistoryMode, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_lines_with_mode_and_wrap_policy( + terminal, + lines, + mode, + HistoryLineWrapPolicy::PreWrap, + ) +} + +pub fn insert_history_lines_with_mode_and_wrap_policy( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result<()> where B: Backend + Write, { @@ -109,12 +132,15 @@ where let mut wrapped_rows = 0usize; for line in &lines { - let line_wrapped = - if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) { + let line_wrapped = match wrap_policy { + HistoryLineWrapPolicy::Terminal => vec![line.clone()], + HistoryLineWrapPolicy::PreWrap + if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) => + { vec![line.clone()] - } else { - adaptive_wrap_line(line, RtOptions::new(wrap_width)) - }; + } + HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(line, RtOptions::new(wrap_width)), + }; wrapped_rows += line_wrapped .iter() .map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width)) @@ -738,6 +764,33 @@ mod tests { ); } + #[test] + fn vt100_terminal_wrap_policy_does_not_pre_wrap_long_paragraph() { + let width: u16 = 20; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let line = Line::from("alpha beta gamma delta epsilon zeta"); + + insert_history_lines_with_mode_and_wrap_policy( + &mut term, + vec![line], + InsertHistoryMode::Standard, + HistoryLineWrapPolicy::Terminal, + ) + .expect("insert raw history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + assert!( + rows.iter() + .any(|row| row.trim_end() == "alpha beta gamma del"), + "expected terminal soft-wrap instead of Codex word pre-wrap, rows: {rows:?}" + ); + } + #[test] fn vt100_unwrapped_url_like_clears_continuation_rows() { let width: u16 = 20; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 5e88423b22..c12d24ca22 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -65,6 +65,8 @@ pub(crate) struct AppKeymap { pub(crate) toggle_vim_mode: Vec, /// Toggle Fast mode. pub(crate) toggle_fast_mode: Vec, + /// Toggle raw scrollback mode for copy-friendly transcript selection. + pub(crate) toggle_raw_output: Vec, } /// Chat-level keybindings evaluated at the app event layer. @@ -377,6 +379,11 @@ impl RuntimeKeymap { &defaults.app.toggle_fast_mode, "tui.keymap.global.toggle_fast_mode", )?, + toggle_raw_output: resolve_bindings( + keymap.global.toggle_raw_output.as_ref(), + &defaults.app.toggle_raw_output, + "tui.keymap.global.toggle_raw_output", + )?, }; let chat = ChatKeymap { @@ -546,6 +553,7 @@ impl RuntimeKeymap { clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], toggle_vim_mode: default_bindings![], toggle_fast_mode: default_bindings![], + toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], }, chat: ChatKeymap { decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], @@ -754,6 +762,7 @@ impl RuntimeKeymap { ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -795,6 +804,7 @@ impl RuntimeKeymap { ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -837,6 +847,7 @@ impl RuntimeKeymap { ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ], [ ("list.move_up", self.list.move_up.as_slice()), @@ -886,6 +897,7 @@ impl RuntimeKeymap { ("composer.submit", self.composer.submit.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), + ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), @@ -1908,6 +1920,28 @@ mod tests { assert!(runtime.composer.toggle_shortcuts.is_empty()); } + #[test] + fn raw_output_toggle_defaults_to_alt_r() { + let runtime = RuntimeKeymap::defaults(); + assert_eq!( + runtime.app.toggle_raw_output, + vec![key_hint::alt(KeyCode::Char('r'))] + ); + } + + #[test] + fn raw_output_toggle_can_be_remapped() { + let mut keymap = TuiKeymap::default(); + keymap.global.toggle_raw_output = Some(one("f12")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.app.toggle_raw_output, + vec![key_hint::plain(KeyCode::F(12))] + ); + } + #[test] fn default_editor_insert_newline_includes_current_aliases() { let runtime = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b6f9660bdd..8e11c55377 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -92,6 +92,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "clear_terminal", "Clear the terminal UI."), action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), + action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -213,6 +214,7 @@ pub(super) fn binding_slot<'a>( ("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal), ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), + ("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -316,6 +318,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()), ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), + ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 85aeca9bc5..b2e92a19b4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -881,38 +881,40 @@ pub async fn run_main( AppServerTarget::Remote { .. } => state_db::get_state_db(&config).await, }; - let effective_toml = config.config_layer_stack.effective_config(); - match effective_toml.try_into() { - Ok(config_toml) => { - match crate::legacy_core::personality_migration::maybe_migrate_personality( - &config.codex_home, - &config_toml, - state_db.clone(), - ) - .await - { - Ok( - crate::legacy_core::personality_migration::PersonalityMigrationStatus::Applied, - ) => { - config = load_config_or_exit( - cli_kv_overrides.clone(), - overrides.clone(), - cloud_requirements.clone(), - ) - .await; - } - Ok( - crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedMarker - | crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality - | crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions, - ) => {} - Err(err) => { - tracing::warn!(error = %err, "failed to run personality migration"); + if let Some(state_db) = state_db.clone() { + let effective_toml = config.config_layer_stack.effective_config(); + match effective_toml.try_into() { + Ok(config_toml) => { + match crate::legacy_core::personality_migration::maybe_migrate_personality( + &config.codex_home, + &config_toml, + state_db, + ) + .await + { + Ok( + crate::legacy_core::personality_migration::PersonalityMigrationStatus::Applied, + ) => { + config = load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await; + } + Ok( + crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedMarker + | crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality + | crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions, + ) => {} + Err(err) => { + tracing::warn!(error = %err, "failed to run personality migration"); + } } } - } - Err(err) => { - tracing::warn!(error = %err, "failed to deserialize config for personality migration"); + Err(err) => { + tracing::warn!(error = %err, "failed to deserialize config for personality migration"); + } } } @@ -1278,16 +1280,12 @@ async fn run_ratatui_app( } } } else if cli.fork_last { - let filter_cwd = if remote_mode { - latest_session_cwd_filter( - remote_mode, - remote_cwd_override.as_deref(), - &config, - cli.fork_show_all, - ) - } else { - None - }; + let filter_cwd = latest_session_cwd_filter( + remote_mode, + remote_cwd_override.as_deref(), + &config, + cli.fork_show_all, + ); let Some(app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --fork --last"); }; @@ -1434,6 +1432,11 @@ async fn run_ratatui_app( None => None, }; + let picker_cancelled_without_selection = matches!( + session_selection, + resume_picker::SessionSelection::StartFresh + ) && (cli.resume_picker || cli.fork_picker); + let mut config = match &session_selection { resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { load_config_or_exit_with_fallback_cwd( @@ -1444,6 +1447,14 @@ async fn run_ratatui_app( ) .await } + resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => { + load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await + } _ => config, }; @@ -1885,6 +1896,181 @@ mod tests { Ok(()) } + #[tokio::test] + async fn latest_session_cwd_filter_respects_scope_options() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let remote_cwd = Path::new("repo/on/server"); + + let local_filter = latest_session_cwd_filter( + /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ false, + ); + let show_all_filter = latest_session_cwd_filter( + /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ true, + ); + let remote_filter = latest_session_cwd_filter( + /*remote_mode*/ true, + Some(remote_cwd), + &config, + /*show_all*/ false, + ); + + assert_eq!(local_filter, Some(config.cwd.as_path())); + assert_eq!(show_all_filter, None); + assert_eq!(remote_filter, Some(remote_cwd)); + Ok(()) + } + + #[tokio::test] + async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> { + fn write_session_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: &str, + cwd: &Path, + ) -> color_eyre::Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let thread_id = ThreadId::from_string(&uuid_str)?; + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let rollout_path = codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); + let parent = rollout_path.parent().ok_or_else(|| { + color_eyre::eyre::eyre!("rollout path is missing a parent directory") + })?; + std::fs::create_dir_all(parent)?; + + let session_meta = codex_protocol::protocol::SessionMeta { + id: thread_id, + timestamp: meta_rfc3339.to_string(), + cwd: cwd.to_path_buf(), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: codex_protocol::protocol::SessionSource::Cli, + model_provider: Some(model_provider.to_string()), + ..Default::default() + }; + let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { + meta: session_meta, + git: None, + })?; + let lines = [ + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": session_meta, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": preview}], + }, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": { + "type": "user_message", + "message": preview, + "kind": "plain", + }, + }) + .to_string(), + ]; + std::fs::write(&rollout_path, lines.join("\n") + "\n")?; + let updated_at = + chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = std::fs::FileTimes::new().set_modified(updated_at.into()); + OpenOptions::new() + .append(true) + .open(rollout_path)? + .set_times(times)?; + + Ok(thread_id) + } + + let temp_dir = TempDir::new()?; + let project_cwd = temp_dir.path().join("project"); + let other_cwd = temp_dir.path().join("other-project"); + std::fs::create_dir_all(&project_cwd)?; + std::fs::create_dir_all(&other_cwd)?; + + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(project_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + let model_provider = config.model_provider_id.as_str(); + let project_thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "older project session", + model_provider, + &project_cwd, + )?; + let other_thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "newer other project session", + model_provider, + &other_cwd, + )?; + + let mut app_server = + AppServerSession::new(codex_app_server_client::AppServerClient::InProcess( + start_test_embedded_app_server(config.clone()).await?, + )); + let filter_cwd = latest_session_cwd_filter( + /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ false, + ); + let scoped_target = lookup_latest_session_target_with_app_server( + &mut app_server, + &config, + filter_cwd, + /*include_non_interactive*/ false, + ) + .await? + .expect("expected project-scoped fork --last target"); + let show_all_filter_cwd = latest_session_cwd_filter( + /*remote_mode*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ true, + ); + let show_all_target = lookup_latest_session_target_with_app_server( + &mut app_server, + &config, + show_all_filter_cwd, + /*include_non_interactive*/ false, + ) + .await? + .expect("expected global fork --last target"); + app_server.shutdown().await?; + + assert_eq!(scoped_target.thread_id, project_thread_id); + assert_eq!(show_all_target.thread_id, other_thread_id); + Ok(()) + } + #[tokio::test] async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 68798ecc0e..be8629542e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -941,6 +941,10 @@ mod tests { self.lines.clone() } + fn raw_lines(&self) -> Vec> { + self.lines.clone() + } + fn transcript_lines(&self, _width: u16) -> Vec> { self.lines.clone() } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index dc148f0059..08bd6657bf 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1,24 +1,39 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +mod transcript; + use crate::app_server_session::AppServerSession; -use crate::diff_render::display_path_for; -use crate::key_hint; +use crate::color::blend; +use crate::color::is_light; +use crate::keymap::PagerKeymap; +use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; +use crate::legacy_core::config::edit::ConfigEditsBuilder; +use crate::markdown::append_markdown; +use crate::pager_overlay::Overlay; use crate::session_resume::resolve_session_thread_id; +use crate::status::format_directory_display; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; +use codex_config::types::SessionPickerViewMode; use codex_protocol::ThreadId; use codex_utils_path as path_utils; use color_eyre::eyre::Result; @@ -29,17 +44,37 @@ use crossterm::event::KeyModifiers; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled as _; use ratatui::style::Stylize as _; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Widget; use tokio::sync::mpsc; use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::warn; +use transcript::RawReasoningVisibility; +use transcript::TranscriptCells; +use transcript::load_session_transcript; use unicode_width::UnicodeWidthStr; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; +const SESSION_META_INDENT_WIDTH: usize = 2; +const SESSION_META_DATE_WIDTH: usize = 12; +const SESSION_META_FIELD_GAP_WIDTH: usize = 2; +const SESSION_META_MIN_CWD_WIDTH: usize = 30; +const SESSION_META_MAX_CWD_WIDTH: usize = 72; +const SESSION_META_BRANCH_ICON: &str = ""; +const SESSION_META_CWD_ICON: &str = "⌁"; +const FOOTER_COMPACT_BREAKPOINT: u16 = 120; +const FOOTER_HINT_LEFT_PADDING: usize = 1; +const FOOTER_HINT_GAP: usize = 3; +const PICKER_CHROME_HEIGHT: u16 = 8; +const PICKER_LIST_HORIZONTAL_INSET: u16 = 4; #[derive(Debug, Clone)] pub struct SessionTarget { @@ -70,6 +105,12 @@ pub enum SessionPickerAction { Fork, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionPickerLaunchContext { + Startup, + ExistingSession, +} + impl SessionPickerAction { fn title(self) -> &'static str { match self { @@ -99,24 +140,117 @@ struct PageLoadRequest { cursor: Option, request_token: usize, search_token: Option, + cwd_filter: Option, provider_filter: ProviderFilter, sort_key: ThreadSortKey, } +enum PickerLoadRequest { + Page(PageLoadRequest), + Preview { thread_id: ThreadId }, + Transcript { thread_id: ThreadId }, +} + #[derive(Clone)] enum ProviderFilter { Any, MatchDefault(String), } -type PageLoader = Arc; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionFilterMode { + Cwd, + All, +} +impl SessionFilterMode { + fn from_show_all(show_all: bool, filter_cwd: Option<&Path>) -> Self { + if show_all || filter_cwd.is_none() { + Self::All + } else { + Self::Cwd + } + } + + fn toggle(self, filter_cwd: Option<&Path>) -> Self { + match self { + Self::Cwd => Self::All, + Self::All if filter_cwd.is_some() => Self::Cwd, + Self::All => Self::All, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ToolbarControl { + Filter, + Sort, +} + +impl ToolbarControl { + fn previous(self) -> Self { + match self { + Self::Filter => Self::Sort, + Self::Sort => Self::Filter, + } + } + + fn next(self) -> Self { + match self { + Self::Filter => Self::Sort, + Self::Sort => Self::Filter, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionListDensity { + Comfortable, + Dense, +} + +impl SessionListDensity { + fn toggle(self) -> Self { + match self { + Self::Comfortable => Self::Dense, + Self::Dense => Self::Comfortable, + } + } +} + +impl From for SessionListDensity { + fn from(mode: SessionPickerViewMode) -> Self { + match mode { + SessionPickerViewMode::Comfortable => Self::Comfortable, + SessionPickerViewMode::Dense => Self::Dense, + } + } +} + +impl From for SessionPickerViewMode { + fn from(density: SessionListDensity) -> Self { + match density { + SessionListDensity::Comfortable => Self::Comfortable, + SessionListDensity::Dense => Self::Dense, + } + } +} + +type PickerLoader = Arc; enum BackgroundEvent { - PageLoaded { + Page { request_token: usize, search_token: Option, page: std::io::Result, }, + Preview { + thread_id: ThreadId, + preview: std::io::Result>, + }, + Transcript { + thread_id: ThreadId, + transcript: std::io::Result, + }, } #[derive(Clone)] @@ -131,12 +265,31 @@ struct PickerPage { reached_scan_cap: bool, } -/// Interactive session picker that lists app-server threads with simple search -/// and pagination. +#[derive(Clone)] +struct SessionPickerViewPersistence { + codex_home: PathBuf, + active_profile: Option, +} + +struct SessionPickerRunOptions { + show_all: bool, + filter_cwd: Option, + local_filter_cwd: Option, + action: SessionPickerAction, + launch_context: SessionPickerLaunchContext, + provider_filter: ProviderFilter, + initial_density: SessionListDensity, + view_persistence: Option, + pager_keymap: PagerKeymap, +} + +/// Interactive session picker that lists app-server threads with simple search, +/// lazy transcript previews, and pagination. /// -/// The picker displays sessions in a table with timestamp columns (created/updated), -/// git branch, working directory, and conversation preview. Users can toggle -/// between sorting by creation time and last-updated time using the Tab key. +/// Sessions render as compact multi-line records with stable metadata first and +/// the conversation preview last. Users can focus Sort/Filter toolbar controls +/// with Tab, change the focused control with the arrow keys, and expand the +/// selected session with Ctrl+E to load recent transcript context on demand. /// /// Sessions are loaded on-demand via cursor-based pagination. The backend /// `thread/list` API returns pages ordered by the selected sort key, and the @@ -152,22 +305,78 @@ pub async fn run_resume_picker_with_app_server( show_all: bool, include_non_interactive: bool, app_server: AppServerSession, +) -> Result { + run_resume_picker_with_launch_context( + tui, + config, + show_all, + include_non_interactive, + app_server, + SessionPickerLaunchContext::Startup, + ) + .await +} + +pub async fn run_resume_picker_from_existing_session_with_app_server( + tui: &mut Tui, + config: &Config, + show_all: bool, + include_non_interactive: bool, + app_server: AppServerSession, +) -> Result { + run_resume_picker_with_launch_context( + tui, + config, + show_all, + include_non_interactive, + app_server, + SessionPickerLaunchContext::ExistingSession, + ) + .await +} + +async fn run_resume_picker_with_launch_context( + tui: &mut Tui, + config: &Config, + show_all: bool, + include_non_interactive: bool, + app_server: AppServerSession, + launch_context: SessionPickerLaunchContext, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), - show_all, + /*show_all*/ false, is_remote, app_server.remote_cwd_override(), ); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); + let provider_filter = picker_provider_filter(config, is_remote); + let pager_keymap = picker_pager_keymap(config)?; + let options = SessionPickerRunOptions { + show_all, + filter_cwd: cwd_filter, + local_filter_cwd, + action: SessionPickerAction::Resume, + launch_context, + provider_filter, + initial_density: SessionListDensity::from(config.tui_session_picker_view), + view_persistence: Some(SessionPickerViewPersistence { + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }), + pager_keymap, + }; run_session_picker_with_loader( tui, - config, - show_all, - SessionPickerAction::Resume, - is_remote, - spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx), + options, + spawn_app_server_page_loader( + app_server, + include_non_interactive, + raw_reasoning_visibility(config), + bg_tx, + ), bg_rx, ) .await @@ -183,18 +392,35 @@ pub async fn run_fork_picker_with_app_server( let is_remote = app_server.is_remote(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), - show_all, + /*show_all*/ false, is_remote, app_server.remote_cwd_override(), ); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); + let provider_filter = picker_provider_filter(config, is_remote); + let pager_keymap = picker_pager_keymap(config)?; + let options = SessionPickerRunOptions { + show_all, + filter_cwd: cwd_filter, + local_filter_cwd, + action: SessionPickerAction::Fork, + launch_context: SessionPickerLaunchContext::Startup, + provider_filter, + initial_density: SessionListDensity::from(config.tui_session_picker_view), + view_persistence: Some(SessionPickerViewPersistence { + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }), + pager_keymap, + }; run_session_picker_with_loader( tui, - config, - show_all, - SessionPickerAction::Fork, - is_remote, + options, spawn_app_server_page_loader( - app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx, + app_server, + /*include_non_interactive*/ false, + raw_reasoning_visibility(config), + bg_tx, ), bg_rx, ) @@ -203,32 +429,24 @@ pub async fn run_fork_picker_with_app_server( async fn run_session_picker_with_loader( tui: &mut Tui, - config: &Config, - show_all: bool, - action: SessionPickerAction, - is_remote: bool, - page_loader: PageLoader, + options: SessionPickerRunOptions, + picker_loader: PickerLoader, bg_rx: mpsc::UnboundedReceiver, ) -> Result { let alt = AltScreenGuard::enter(tui); - let provider_filter = if is_remote { - ProviderFilter::Any - } else { - ProviderFilter::MatchDefault(config.model_provider_id.to_string()) - }; - // Remote sessions live in the server's filesystem namespace, so the client - // process cwd is not a meaningful row filter. Local cwd filtering and explicit - // remote --cd filtering are handled server-side in thread/list. - let filter_cwd = None; - let mut state = PickerState::new( alt.tui.frame_requester(), - page_loader, - provider_filter, - show_all, - filter_cwd, - action, + picker_loader, + options.provider_filter, + options.show_all, + options.filter_cwd, + options.action, ); + state.local_filter_cwd = options.local_filter_cwd; + state.density = options.initial_density; + state.view_persistence = options.view_persistence; + state.pager_keymap = options.pager_keymap; + state.launch_context = options.launch_context; state.start_initial_load(); state.request_frame(); @@ -238,6 +456,10 @@ async fn run_session_picker_with_loader( loop { tokio::select! { Some(ev) = tui_events.next() => { + if state.overlay.is_some() { + state.handle_overlay_event(alt.tui, ev)?; + continue; + } match ev { TuiEvent::Key(key) => { if matches!(key.kind, KeyEventKind::Release) { @@ -249,11 +471,15 @@ async fn run_session_picker_with_loader( } TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { - let list_height = size.height.saturating_sub(4) as usize; - state.update_view_rows(list_height); + let list_height = + size.height.saturating_sub(PICKER_CHROME_HEIGHT) as usize; + state.update_viewport(list_height, list_viewport_width(size.width)); state.ensure_minimum_rows_for_view(list_height); } draw_picker(alt.tui, &state)?; + if state.note_transcript_loading_frame_drawn() { + state.open_pending_transcript_if_ready(); + } } _ => {} } @@ -269,6 +495,32 @@ async fn run_session_picker_with_loader( Ok(SessionSelection::StartFresh) } +fn raw_reasoning_visibility(config: &Config) -> RawReasoningVisibility { + if config.show_raw_agent_reasoning { + RawReasoningVisibility::Visible + } else { + RawReasoningVisibility::Hidden + } +} + +fn local_picker_cwd_filter(cwd_filter: &Option, is_remote: bool) -> Option { + if is_remote { None } else { cwd_filter.clone() } +} + +fn picker_provider_filter(config: &Config, is_remote: bool) -> ProviderFilter { + if is_remote { + ProviderFilter::Any + } else { + ProviderFilter::MatchDefault(config.model_provider_id.to_string()) + } +} + +fn picker_pager_keymap(config: &Config) -> Result { + RuntimeKeymap::from_config(&config.tui_keymap) + .map(|keymap| keymap.pager) + .map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}")) +} + fn picker_cwd_filter( config_cwd: &Path, show_all: bool, @@ -286,37 +538,57 @@ fn picker_cwd_filter( fn spawn_app_server_page_loader( app_server: AppServerSession, - cwd_filter: Option, include_non_interactive: bool, + raw_reasoning_visibility: RawReasoningVisibility, bg_tx: mpsc::UnboundedSender, -) -> PageLoader { - let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); +) -> PickerLoader { + let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); tokio::spawn(async move { let mut app_server = app_server; while let Some(request) = request_rx.recv().await { - let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); - let page = load_app_server_page( - &mut app_server, - cursor, - cwd_filter.as_deref(), - request.provider_filter, - request.sort_key, - include_non_interactive, - ) - .await; - let _ = bg_tx.send(BackgroundEvent::PageLoaded { - request_token: request.request_token, - search_token: request.search_token, - page, - }); + match request { + PickerLoadRequest::Page(request) => { + let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); + let page = load_app_server_page( + &mut app_server, + cursor, + request.cwd_filter.as_deref(), + request.provider_filter, + request.sort_key, + include_non_interactive, + ) + .await; + let _ = bg_tx.send(BackgroundEvent::Page { + request_token: request.request_token, + search_token: request.search_token, + page, + }); + } + PickerLoadRequest::Preview { thread_id } => { + let preview = load_transcript_preview(&mut app_server, thread_id).await; + let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview }); + } + PickerLoadRequest::Transcript { thread_id } => { + let transcript = load_session_transcript( + &mut app_server, + thread_id, + raw_reasoning_visibility, + ) + .await; + let _ = bg_tx.send(BackgroundEvent::Transcript { + thread_id, + transcript, + }); + } + } } if let Err(err) = app_server.shutdown().await { warn!(%err, "Failed to shut down app-server picker session"); } }); - Arc::new(move |request: PageLoadRequest| { + Arc::new(move |request: PickerLoadRequest| { let _ = request_tx.send(request); }) } @@ -329,9 +601,6 @@ fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { } } -const CREATED_COLUMN_LABEL: &str = "Created"; -const UPDATED_COLUMN_LABEL: &str = "Updated"; - /// RAII guard that ensures we leave the alt-screen on scope exit. struct AltScreenGuard<'a> { tui: &'a mut Tui, @@ -359,18 +628,33 @@ struct PickerState { seen_rows: HashSet, selected: usize, scroll_top: usize, + pending_page_down_target: Option, + frozen_footer_percent: Option, query: String, search_state: SearchState, next_request_token: usize, next_search_token: usize, - page_loader: PageLoader, + picker_loader: PickerLoader, view_rows: Option, + view_width: Option, provider_filter: ProviderFilter, - show_all: bool, + filter_mode: SessionFilterMode, filter_cwd: Option, + local_filter_cwd: Option, + toolbar_focus: ToolbarControl, + density: SessionListDensity, + launch_context: SessionPickerLaunchContext, + view_persistence: Option, action: SessionPickerAction, sort_key: ThreadSortKey, inline_error: Option, + expanded_thread_id: Option, + transcript_previews: HashMap, + transcript_cells: HashMap, + pending_transcript_open: Option, + transcript_loading_frame_shown: bool, + overlay: Option, + pager_keymap: PagerKeymap, } struct PaginationState { @@ -398,6 +682,31 @@ enum SearchState { Active { token: usize }, } +#[derive(Clone)] +enum TranscriptPreviewState { + Loading, + Loaded(Vec), + Failed, +} + +enum SessionTranscriptState { + Loading, + Loaded(TranscriptCells), + Failed, +} + +#[derive(Clone)] +struct TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker, + text: String, +} + +#[derive(Clone, Copy)] +enum TranscriptPreviewSpeaker { + User, + Assistant, +} + enum LoadTrigger { Scroll, Search { token: usize }, @@ -441,6 +750,57 @@ async fn load_app_server_page( }) } +async fn load_transcript_preview( + app_server: &mut AppServerSession, + thread_id: ThreadId, +) -> std::io::Result> { + const MAX_PREVIEW_LINES: usize = 6; + + let thread = app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + .map_err(std::io::Error::other)?; + let mut lines = thread + .turns + .iter() + .flat_map(|turn| turn.items.iter()) + .filter_map(|item| match item { + ThreadItem::UserMessage { content, .. } => Some(TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::User, + text: content + .iter() + .filter_map(|input| match input { + codex_app_server_protocol::UserInput::Text { text, .. } => { + Some(text.as_str()) + } + _ => None, + }) + .collect::>() + .join(" "), + }), + ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text: text.clone(), + }), + _ => None, + }) + .flat_map(|line| { + line.text + .lines() + .filter(|text| !text.trim().is_empty()) + .map(move |text| TranscriptPreviewLine { + speaker: line.speaker, + text: text.trim().to_string(), + }) + .collect::>() + }) + .collect::>(); + if lines.len() > MAX_PREVIEW_LINES { + lines.drain(..lines.len() - MAX_PREVIEW_LINES); + } + Ok(lines) +} + impl SearchState { fn active_token(&self) -> Option { match self { @@ -493,6 +853,26 @@ impl Row { { return true; } + if self + .thread_id + .is_some_and(|thread_id| thread_id.to_string().to_lowercase().contains(query)) + { + return true; + } + if self + .git_branch + .as_ref() + .is_some_and(|branch| branch.to_lowercase().contains(query)) + { + return true; + } + if self + .cwd + .as_ref() + .is_some_and(|cwd| cwd.to_string_lossy().to_lowercase().contains(query)) + { + return true; + } false } } @@ -500,7 +880,7 @@ impl Row { impl PickerState { fn new( requester: FrameRequester, - page_loader: PageLoader, + picker_loader: PickerLoader, provider_filter: ProviderFilter, show_all: bool, filter_cwd: Option, @@ -520,18 +900,33 @@ impl PickerState { seen_rows: HashSet::new(), selected: 0, scroll_top: 0, + pending_page_down_target: None, + frozen_footer_percent: None, query: String::new(), search_state: SearchState::Idle, next_request_token: 0, next_search_token: 0, - page_loader, + picker_loader, view_rows: None, + view_width: None, provider_filter, - show_all, + filter_mode: SessionFilterMode::from_show_all(show_all, filter_cwd.as_deref()), + local_filter_cwd: filter_cwd.clone(), filter_cwd, + toolbar_focus: ToolbarControl::Filter, + density: SessionListDensity::Comfortable, + launch_context: SessionPickerLaunchContext::Startup, + view_persistence: None, action, sort_key: ThreadSortKey::UpdatedAt, inline_error: None, + expanded_thread_id: None, + transcript_previews: HashMap::new(), + transcript_cells: HashMap::new(), + pending_transcript_open: None, + transcript_loading_frame_shown: false, + overlay: None, + pager_keymap: RuntimeKeymap::defaults().pager, } } @@ -539,12 +934,111 @@ impl PickerState { self.requester.schedule_frame(); } + fn is_transcript_loading(&self) -> bool { + self.pending_transcript_open.is_some() + } + + fn note_transcript_loading_frame_drawn(&mut self) -> bool { + if self.pending_transcript_open.is_some() { + self.transcript_loading_frame_shown = true; + true + } else { + false + } + } + + fn open_pending_transcript_if_ready(&mut self) { + if !self.transcript_loading_frame_shown { + return; + } + let Some(thread_id) = self.pending_transcript_open else { + return; + }; + let Some(SessionTranscriptState::Loaded(cells)) = self.transcript_cells.get(&thread_id) + else { + return; + }; + self.overlay = Some(Overlay::new_transcript( + cells.clone(), + self.pager_keymap.clone(), + )); + self.pending_transcript_open = None; + self.transcript_loading_frame_shown = false; + self.request_frame(); + } + + fn begin_transcript_loading(&mut self, thread_id: ThreadId) { + self.pending_transcript_open = Some(thread_id); + self.transcript_loading_frame_shown = false; + self.request_frame(); + } + + fn handle_overlay_event(&mut self, tui: &mut Tui, event: TuiEvent) -> Result<()> { + let Some(overlay) = &mut self.overlay else { + return Ok(()); + }; + overlay.handle_event(tui, event)?; + if overlay.is_done() { + self.overlay = None; + self.request_frame(); + } + Ok(()) + } + + fn open_selected_transcript(&mut self) { + let Some(row) = self.filtered_rows.get(self.selected) else { + return; + }; + let Some(thread_id) = row.thread_id else { + self.inline_error = Some("No transcript available for this session".to_string()); + self.request_frame(); + return; + }; + + match self.transcript_cells.get(&thread_id) { + Some(SessionTranscriptState::Loaded(_)) => { + self.begin_transcript_loading(thread_id); + } + Some(SessionTranscriptState::Loading) => { + self.begin_transcript_loading(thread_id); + } + Some(SessionTranscriptState::Failed) | None => { + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Loading); + self.begin_transcript_loading(thread_id); + (self.picker_loader)(PickerLoadRequest::Transcript { thread_id }); + } + } + } + + fn handle_transcript_loading_key(&mut self, key: KeyEvent) -> Option { + match key { + KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit), + _ => None, + } + } + async fn handle_key(&mut self, key: KeyEvent) -> Result> { self.inline_error = None; + if self.is_transcript_loading() { + return Ok(self.handle_transcript_loading_key(key)); + } + if !matches!(key.code, KeyCode::PageDown) { + self.pending_page_down_target = None; + } match key { KeyEvent { code: KeyCode::Esc, .. - } => return Ok(Some(SessionSelection::StartFresh)), + } => { + if self.query.is_empty() { + return Ok(Some(SessionSelection::StartFresh)); + } + self.clear_query_preserving_selection(); + } KeyEvent { code: KeyCode::Char('c'), modifiers, @@ -552,6 +1046,48 @@ impl PickerState { } if modifiers.contains(KeyModifiers::CONTROL) => { return Ok(Some(SessionSelection::Exit)); } + KeyEvent { + code: KeyCode::Char('t'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.open_selected_transcript(); + } + KeyEvent { + code: KeyCode::Char('e'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.toggle_selected_expansion(); + } + KeyEvent { + code: KeyCode::Char('\u{0014}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^T */ => { + self.open_selected_transcript(); + } + KeyEvent { + code: KeyCode::Char('\u{0005}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^E */ => { + self.toggle_selected_expansion(); + } + KeyEvent { + code: KeyCode::Char('o'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.toggle_density().await; + } + KeyEvent { + code: KeyCode::Char('\u{000f}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^O */ => { + self.toggle_density().await; + } KeyEvent { code: KeyCode::Enter, .. @@ -634,22 +1170,66 @@ impl PickerState { } } KeyEvent { - code: KeyCode::PageDown, + code: KeyCode::Home, .. } => { if !self.filtered_rows.is_empty() { - let step = self.view_rows.unwrap_or(10).max(1); - let max_index = self.filtered_rows.len().saturating_sub(1); - self.selected = (self.selected + step).min(max_index); + self.selected = 0; + self.ensure_selected_visible(); + self.request_frame(); + } + } + KeyEvent { + code: KeyCode::End, .. + } => { + if !self.filtered_rows.is_empty() { + self.selected = self.filtered_rows.len().saturating_sub(1); self.ensure_selected_visible(); self.maybe_load_more_for_scroll(); self.request_frame(); } } + KeyEvent { + code: KeyCode::PageDown, + .. + } => { + if !self.filtered_rows.is_empty() { + let step = self.view_rows.unwrap_or(10).max(1); + let target = self.selected.saturating_add(step); + let max_index = self.filtered_rows.len().saturating_sub(1); + if target > max_index && self.pagination.next_cursor.is_some() { + self.pending_page_down_target = Some(target); + self.load_more_if_needed(LoadTrigger::Scroll); + } else { + self.selected = target.min(max_index); + self.ensure_selected_visible(); + self.maybe_load_more_for_scroll(); + } + self.request_frame(); + } + } KeyEvent { code: KeyCode::Tab, .. } => { - self.toggle_sort_key(); + self.focus_next_toolbar_control(); + self.request_frame(); + } + KeyEvent { + code: KeyCode::BackTab, + .. + } => { + self.focus_previous_toolbar_control(); + self.request_frame(); + } + KeyEvent { + code: KeyCode::Left, + .. + } + | KeyEvent { + code: KeyCode::Right, + .. + } => { + self.change_focused_toolbar_value(); self.request_frame(); } KeyEvent { @@ -686,6 +1266,8 @@ impl PickerState { self.filtered_rows.clear(); self.seen_rows.clear(); self.selected = 0; + self.pending_page_down_target = None; + self.frozen_footer_percent = None; let search_token = if self.query.is_empty() { self.search_state = SearchState::Idle; @@ -703,18 +1285,19 @@ impl PickerState { }); self.request_frame(); - (self.page_loader)(PageLoadRequest { + (self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest { cursor: None, request_token, search_token, + cwd_filter: self.active_cwd_filter(), provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, - }); + })); } async fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { match event { - BackgroundEvent::PageLoaded { + BackgroundEvent::Page { request_token, search_token, page, @@ -729,9 +1312,44 @@ impl PickerState { self.pagination.loading = LoadingState::Idle; let page = page.map_err(color_eyre::Report::from)?; self.ingest_page(page); + self.complete_pending_page_down(); let completed_token = pending.search_token.or(search_token); self.continue_search_if_token_matches(completed_token); } + BackgroundEvent::Preview { thread_id, preview } => { + self.transcript_previews.insert( + thread_id, + match preview { + Ok(lines) => TranscriptPreviewState::Loaded(lines), + Err(_) => TranscriptPreviewState::Failed, + }, + ); + self.request_frame(); + } + BackgroundEvent::Transcript { + thread_id, + transcript, + } => match transcript { + Ok(cells) => { + let should_open = self.pending_transcript_open == Some(thread_id); + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Loaded(cells.clone())); + if should_open { + self.open_pending_transcript_if_ready(); + } + self.request_frame(); + } + Err(_) => { + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Failed); + if self.pending_transcript_open == Some(thread_id) { + self.pending_transcript_open = None; + self.transcript_loading_frame_shown = false; + self.inline_error = Some("Could not load transcript preview".to_string()); + } + self.request_frame(); + } + }, } Ok(()) } @@ -741,6 +1359,7 @@ impl PickerState { self.pagination.num_scanned_files = 0; self.pagination.reached_scan_cap = false; self.pagination.loading = LoadingState::Idle; + self.frozen_footer_percent = None; } fn ingest_page(&mut self, page: PickerPage) { @@ -770,6 +1389,27 @@ impl PickerState { self.apply_filter(); } + fn complete_pending_page_down(&mut self) { + let Some(target) = self.pending_page_down_target else { + return; + }; + if self.filtered_rows.is_empty() { + return; + } + + let max_index = self.filtered_rows.len().saturating_sub(1); + if target > max_index && self.pagination.next_cursor.is_some() { + self.load_more_if_needed(LoadTrigger::Scroll); + return; + } + + self.pending_page_down_target = None; + self.selected = target.min(max_index); + self.ensure_selected_visible(); + self.maybe_load_more_for_scroll(); + self.request_frame(); + } + fn apply_filter(&mut self) { let base_iter = self .all_rows @@ -792,10 +1432,10 @@ impl PickerState { } fn row_matches_filter(&self, row: &Row) -> bool { - if self.show_all { + if self.filter_mode == SessionFilterMode::All { return true; } - let Some(filter_cwd) = self.filter_cwd.as_ref() else { + let Some(filter_cwd) = self.local_filter_cwd.as_ref() else { return true; }; let Some(row_cwd) = row.cwd.as_ref() else { @@ -828,6 +1468,26 @@ impl PickerState { self.load_more_if_needed(LoadTrigger::Search { token }); } + fn clear_query_preserving_selection(&mut self) { + let selected_key = self + .filtered_rows + .get(self.selected) + .and_then(Row::seen_key); + self.query.clear(); + self.search_state = SearchState::Idle; + self.apply_filter(); + if let Some(selected_key) = selected_key + && let Some(index) = self + .filtered_rows + .iter() + .position(|row| row.seen_key().as_ref() == Some(&selected_key)) + { + self.selected = index; + self.ensure_selected_visible(); + self.request_frame(); + } + } + fn continue_search_if_needed(&mut self) { let Some(token) = self.search_state.active_token() else { return; @@ -860,20 +1520,15 @@ impl PickerState { self.scroll_top = 0; return; } - let capacity = self.view_rows.unwrap_or(self.filtered_rows.len()).max(1); - + let viewport_rows = self.view_rows.unwrap_or(usize::MAX).max(1); if self.selected < self.scroll_top { self.scroll_top = self.selected; - } else { - let last_visible = self.scroll_top.saturating_add(capacity - 1); - if self.selected > last_visible { - self.scroll_top = self.selected.saturating_sub(capacity - 1); - } } - - let max_start = self.filtered_rows.len().saturating_sub(capacity); - if self.scroll_top > max_start { - self.scroll_top = max_start; + while self.rendered_height_between(self.scroll_top, self.selected) + > self.available_content_rows(viewport_rows) + && self.scroll_top < self.selected + { + self.scroll_top += 1; } } @@ -881,10 +1536,15 @@ impl PickerState { if minimum_rows == 0 { return; } - if self.filtered_rows.len() >= minimum_rows { + if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() { return; } - if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() { + let rendered_rows = if self.filtered_rows.is_empty() { + 0 + } else { + self.rendered_height_between(/*start*/ 0, self.filtered_rows.len() - 1) + }; + if rendered_rows >= self.available_content_rows(minimum_rows) { return; } if let Some(token) = self.search_state.active_token() { @@ -894,8 +1554,9 @@ impl PickerState { } } - fn update_view_rows(&mut self, rows: usize) { + fn update_viewport(&mut self, rows: usize, width: u16) { self.view_rows = if rows == 0 { None } else { Some(rows) }; + self.view_width = Some(width); self.ensure_selected_visible(); } @@ -922,6 +1583,7 @@ impl PickerState { let Some(cursor) = self.pagination.next_cursor.clone() else { return; }; + self.freeze_footer_percent(); let request_token = self.allocate_request_token(); let search_token = match trigger { LoadTrigger::Scroll => None, @@ -933,13 +1595,19 @@ impl PickerState { }); self.request_frame(); - (self.page_loader)(PageLoadRequest { + (self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest { cursor: Some(cursor), request_token, search_token, + cwd_filter: self.active_cwd_filter(), provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, - }); + })); + } + + fn freeze_footer_percent(&mut self) { + let list_height = self.view_rows.unwrap_or_default().min(u16::MAX as usize) as u16; + self.frozen_footer_percent = Some(picker_footer_scroll_percent(self, list_height)); } fn allocate_request_token(&mut self) -> usize { @@ -966,6 +1634,163 @@ impl PickerState { }; self.start_initial_load(); } + + fn toggle_filter_mode(&mut self) { + let next_filter_mode = self.filter_mode.toggle(self.filter_cwd.as_deref()); + if self.filter_mode == next_filter_mode { + return; + } + self.filter_mode = next_filter_mode; + self.start_initial_load(); + } + + fn active_cwd_filter(&self) -> Option { + match self.filter_mode { + SessionFilterMode::Cwd => self.filter_cwd.clone(), + SessionFilterMode::All => None, + } + } + + fn focus_previous_toolbar_control(&mut self) { + self.toolbar_focus = self.toolbar_focus.previous(); + } + + fn focus_next_toolbar_control(&mut self) { + self.toolbar_focus = self.toolbar_focus.next(); + } + + fn change_focused_toolbar_value(&mut self) { + match self.toolbar_focus { + ToolbarControl::Sort => self.toggle_sort_key(), + ToolbarControl::Filter => self.toggle_filter_mode(), + } + } + + async fn toggle_density(&mut self) { + self.density = self.density.toggle(); + self.ensure_selected_visible(); + if let Err(err) = self.persist_density().await { + warn!(error = %err, "failed to persist session picker view mode"); + self.inline_error = Some(format!("Failed to save view mode: {err}")); + } + self.request_frame(); + } + + async fn persist_density(&self) -> Result<()> { + let Some(persistence) = &self.view_persistence else { + return Ok(()); + }; + + ConfigEditsBuilder::new(&persistence.codex_home) + .with_profile(persistence.active_profile.as_deref()) + .set_session_picker_view(SessionPickerViewMode::from(self.density)) + .apply() + .await + .map_err(|err| color_eyre::eyre::eyre!("failed to write config.toml: {err}"))?; + + Ok(()) + } + + fn toggle_selected_expansion(&mut self) { + let Some(row) = self.filtered_rows.get(self.selected) else { + return; + }; + let Some(thread_id) = row.thread_id else { + return; + }; + if self.expanded_thread_id == Some(thread_id) { + self.expanded_thread_id = None; + self.request_frame(); + return; + } + self.expanded_thread_id = Some(thread_id); + if let std::collections::hash_map::Entry::Vacant(e) = + self.transcript_previews.entry(thread_id) + { + e.insert(TranscriptPreviewState::Loading); + (self.picker_loader)(PickerLoadRequest::Preview { thread_id }); + } + self.request_frame(); + } + + fn rendered_height_between(&self, start: usize, end_inclusive: usize) -> usize { + self.filtered_rows + .get(start..=end_inclusive) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(offset, row)| { + let row_idx = start + offset; + let is_selected = row_idx == self.selected; + let is_expanded = is_selected + && row.thread_id.is_some() + && self.expanded_thread_id == row.thread_id; + render_session_lines( + row, + self, + is_selected, + is_expanded, + /*is_zebra*/ false, + self.view_width.unwrap_or(u16::MAX), + ) + .len() + }) + .sum::() + + self.row_separator_height() * end_inclusive.saturating_sub(start) + } + + fn has_more_above(&self) -> bool { + self.scroll_top > 0 + } + + fn has_more_below(&self, viewport_height: usize) -> bool { + if self.filtered_rows.is_empty() { + return false; + } + if self.pagination.next_cursor.is_some() { + return true; + } + let capacity = self.available_content_rows(viewport_height); + let mut used = 0usize; + for (offset, row) in self.filtered_rows[self.scroll_top..].iter().enumerate() { + let row_idx = self.scroll_top + offset; + let is_selected = row_idx == self.selected; + let is_expanded = + is_selected && row.thread_id.is_some() && self.expanded_thread_id == row.thread_id; + let row_height = render_session_lines( + row, + self, + is_selected, + is_expanded, + /*is_zebra*/ false, + self.view_width.unwrap_or(u16::MAX), + ) + .len(); + let separator_height = usize::from(offset > 0) * self.row_separator_height(); + if used + separator_height + row_height > capacity { + return true; + } + used += separator_height + row_height; + } + false + } + + fn available_content_rows(&self, viewport_height: usize) -> usize { + viewport_height + .saturating_sub(usize::from(self.has_more_above())) + .saturating_sub(usize::from( + self.pagination.next_cursor.is_some() + || self.selected + 1 < self.filtered_rows.len(), + )) + .max(1) + } + + fn row_separator_height(&self) -> usize { + match self.density { + SessionListDensity::Comfortable => 1, + SessionListDensity::Dense => 0, + } + } } fn row_from_app_server_thread(thread: Thread) -> Option { @@ -1036,83 +1861,582 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { let height = tui.terminal.size()?.height; tui.draw(height, |frame| { let area = frame.area(); - let [header, search, columns, list, hint] = Layout::vertical([ + let [header, _header_gap, search, _search_gap, list, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(4)), Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(PICKER_CHROME_HEIGHT)), + Constraint::Length(4), ]) .areas(area); + let chrome = |area: Rect| { + Rect::new( + area.x.saturating_add(1), + area.y, + area.width.saturating_sub(2), + area.height, + ) + }; + // Header - let header_line: Line = vec![ - state.action.title().bold().cyan(), - " ".into(), - "Sort:".dim(), - " ".into(), - sort_key_label(state.sort_key).magenta(), - ] - .into(); - frame.render_widget_ref(header_line, header); + let header_title = if default_bg().is_some_and(is_light) { + state.action.title().bold().fg(best_color((0, 100, 0))) + } else { + state.action.title().bold().cyan() + }; + let header_line: Line = vec![header_title].into(); + frame.render_widget_ref(header_line, chrome(header)); // Search line - frame.render_widget_ref(search_line(state), search); + let search = chrome(search); + frame.render_widget_ref(search_line(state, search.width), search); - let metrics = calculate_column_metrics( - &state.filtered_rows, - state.show_all, - state.relative_time_reference.unwrap_or_else(Utc::now), + let list = Rect::new( + list.x.saturating_add(2), + list.y, + list_viewport_width(list.width), + list.height, ); + render_list(frame, list, state); + if state.is_transcript_loading() { + render_transcript_loading_overlay(frame, list); + } - // Column headers and list - render_column_headers(frame, columns, &metrics, state.sort_key); - render_list(frame, list, state, &metrics); - - // Hint line - let action_label = state.action.action_label(); - let hint_line: Line = vec![ - key_hint::plain(KeyCode::Enter).into(), - format!(" to {action_label} ").dim(), - " ".dim(), - key_hint::plain(KeyCode::Esc).into(), - " to start new ".dim(), - " ".dim(), - key_hint::ctrl(KeyCode::Char('c')).into(), - " to quit ".dim(), - " ".dim(), - key_hint::plain(KeyCode::Tab).into(), - " to toggle sort ".dim(), - " ".dim(), - key_hint::plain(KeyCode::Up).into(), - "/".dim(), - key_hint::plain(KeyCode::Down).into(), - " to browse".dim(), - ] - .into(); - frame.render_widget_ref(hint_line, hint); + render_picker_footer(frame, footer, state, list.height); }) } -fn search_line(state: &PickerState) -> Line<'_> { +fn list_viewport_width(width: u16) -> u16 { + width.saturating_sub(PICKER_LIST_HORIZONTAL_INSET) +} + +fn search_line(state: &PickerState, width: u16) -> Line<'_> { if let Some(error) = state.inline_error.as_deref() { return Line::from(error.red()); } - if state.query.is_empty() { - return Line::from("Type to search".dim()); + let search = if state.query.is_empty() { + "Type to search".dim() + } else { + format!("Search: {}", state.query).into() + }; + let mut toolbar = toolbar_line(state, /*compact*/ false); + if toolbar.width() as u16 > width.saturating_sub(2) { + toolbar = toolbar_line(state, /*compact*/ true); } - Line::from(format!("Search: {}", state.query)) + let search_width = UnicodeWidthStr::width(search.content.as_ref()); + let toolbar_width = toolbar.width(); + let spacer_width = width + .saturating_sub((search_width + toolbar_width) as u16) + .max(2) as usize; + let available_search_width = width + .saturating_sub(toolbar_width as u16) + .saturating_sub(spacer_width as u16) as usize; + let search = if search_width > available_search_width { + let truncated = truncate_text(search.content.as_ref(), available_search_width); + if state.query.is_empty() { + truncated.dim() + } else { + truncated.into() + } + } else { + search + }; + + let mut spans = vec![search, " ".repeat(spacer_width).into()]; + spans.extend(toolbar.spans); + spans.into() } -fn render_list( +fn toolbar_line(state: &PickerState, compact: bool) -> Line<'static> { + let mut spans = Vec::new(); + spans.extend(filter_control_spans(state, compact)); + spans.push(" ".dim()); + spans.extend(sort_control_spans(state, compact)); + spans.into() +} + +fn sort_control_spans(state: &PickerState, compact: bool) -> Vec> { + let sort_focused = state.toolbar_focus == ToolbarControl::Sort; + if compact { + return vec![ + "Sort:".dim(), + toolbar_value( + sort_key_label(state.sort_key), + /*active*/ true, + sort_focused, + ), + ]; + } + vec![ + "Sort: ".dim(), + toolbar_value( + sort_key_label(ThreadSortKey::UpdatedAt), + state.sort_key == ThreadSortKey::UpdatedAt, + sort_focused, + ), + toolbar_value( + sort_key_label(ThreadSortKey::CreatedAt), + state.sort_key == ThreadSortKey::CreatedAt, + sort_focused, + ), + ] +} + +fn filter_control_spans(state: &PickerState, compact: bool) -> Vec> { + let filter_focused = state.toolbar_focus == ToolbarControl::Filter; + if compact || state.filter_cwd.is_none() { + return vec![ + "Filter:".dim(), + toolbar_value( + filter_mode_label(state.filter_mode), + /*active*/ true, + filter_focused, + ), + ]; + } + vec![ + "Filter: ".dim(), + toolbar_value( + filter_mode_label(SessionFilterMode::Cwd), + state.filter_mode == SessionFilterMode::Cwd, + filter_focused, + ), + toolbar_value( + filter_mode_label(SessionFilterMode::All), + state.filter_mode == SessionFilterMode::All, + filter_focused, + ), + ] +} + +fn toolbar_value(label: &'static str, active: bool, focused: bool) -> Span<'static> { + if active { + let value = format!("[{label}]"); + if focused { + value.magenta() + } else { + value.into() + } + } else { + format!(" {label} ").dim() + } +} + +fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str { + match filter_mode { + SessionFilterMode::Cwd => "Cwd", + SessionFilterMode::All => "All", + } +} + +struct PickerFooterHint { + key: &'static str, + wide_label: String, + compact_label: String, + priority: u8, +} + +fn render_picker_footer( frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState, - metrics: &ColumnMetrics, + list_height: u16, ) { + if area.width == 0 || area.height == 0 { + return; + } + + let separator = Rect::new(area.x, area.y, area.width, 1); + render_picker_footer_separator( + frame, + separator, + picker_footer_progress_label(state, list_height, area.width), + ); + + let lines = footer_hint_lines(state, area.width); + for (idx, line) in lines.into_iter().enumerate() { + let y = area.y.saturating_add(1 + idx as u16); + if y >= area.bottom() { + break; + } + frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1)); + } +} + +fn render_picker_footer_separator( + frame: &mut crate::custom_terminal::Frame, + area: Rect, + progress_label: String, +) { + if area.width == 0 { + return; + } + + let separator = "─".repeat(area.width as usize); + frame.render_widget_ref(Line::from(separator.dim()), area); + + let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16; + if progress_width < area.width { + let percent_area = Rect::new( + area.x + area.width - progress_width - 1, + area.y, + progress_width, + 1, + ); + frame.render_widget_ref(Line::from(progress_label.dim()), percent_area); + } +} + +fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u16) -> String { + let position = if state.filtered_rows.is_empty() { + 0 + } else { + state.selected.saturating_add(1) + }; + let total = if state.pagination.loading.is_pending() { + format!("{}…", state.filtered_rows.len()) + } else { + state.filtered_rows.len().to_string() + }; + let percent = picker_footer_percent(state, list_height); + let labels = [ + format!(" {position} / {total} · {percent}% "), + format!(" {position}/{total} · {percent}% "), + format!(" {percent}% "), + ]; + labels + .into_iter() + .find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize) + .unwrap_or_default() +} + +fn picker_footer_percent(state: &PickerState, list_height: u16) -> u8 { + if state.pagination.loading.is_pending() { + return state.frozen_footer_percent.unwrap_or_else(|| { + if state.filtered_rows.is_empty() { + 0 + } else { + picker_footer_scroll_percent(state, list_height) + } + }); + } + + picker_footer_scroll_percent(state, list_height) +} + +fn picker_footer_scroll_percent(state: &PickerState, list_height: u16) -> u8 { + if state.filtered_rows.is_empty() { + return 100; + } + + let content_rows = state.available_content_rows(list_height as usize); + let total_height = + state.rendered_height_between(/*start*/ 0, state.filtered_rows.len() - 1); + let max_scroll = total_height.saturating_sub(content_rows); + if max_scroll == 0 { + return 100; + } + let remaining_height = + state.rendered_height_between(state.scroll_top, state.filtered_rows.len() - 1); + if remaining_height <= content_rows { + return 100; + } + + let skipped_height = if state.scroll_top == 0 { + 0 + } else { + state.rendered_height_between(/*start*/ 0, state.scroll_top - 1) + }; + (((skipped_height.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8 +} + +fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { + if state.is_transcript_loading() { + let hints = [ + PickerFooterHint { + key: "loading", + wide_label: String::from("transcript"), + compact_label: String::from("transcript"), + priority: 0, + }, + PickerFooterHint { + key: "ctrl+c", + wide_label: String::from("quit"), + compact_label: String::from("quit"), + priority: 1, + }, + ]; + let line = fit_footer_hints(&hints, FooterHintLabelMode::Wide, width) + .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::Compact, width)) + .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::KeyOnly, width)) + .unwrap_or_default(); + return vec![line, Line::default()]; + } + + let action_label = state.action.action_label(); + let (esc_label, esc_compact_label) = if state.query.is_empty() { + match state.launch_context { + SessionPickerLaunchContext::Startup => ("start new", "new"), + SessionPickerLaunchContext::ExistingSession => ("exit", "exit"), + } + } else { + ("clear search", "clear") + }; + let ctrl_c_label = match state.launch_context { + SessionPickerLaunchContext::Startup => "quit", + SessionPickerLaunchContext::ExistingSession => "exit", + }; + let density_label = match state.density { + SessionListDensity::Comfortable => "dense view", + SessionListDensity::Dense => "comfortable view", + }; + let density_compact_label = match state.density { + SessionListDensity::Comfortable => "dense", + SessionListDensity::Dense => "comfy", + }; + let first_row_hints = vec![ + PickerFooterHint { + key: "enter", + wide_label: action_label.to_string(), + compact_label: action_label.to_string(), + priority: 0, + }, + PickerFooterHint { + key: "esc", + wide_label: esc_label.to_string(), + compact_label: esc_compact_label.to_string(), + priority: 1, + }, + PickerFooterHint { + key: "ctrl+c", + wide_label: ctrl_c_label.to_string(), + compact_label: ctrl_c_label.to_string(), + priority: 2, + }, + PickerFooterHint { + key: "tab", + wide_label: String::from("focus sort/filter"), + compact_label: String::from("focus"), + priority: 7, + }, + PickerFooterHint { + key: "←/→", + wide_label: String::from("change option"), + compact_label: String::from("option"), + priority: 8, + }, + ]; + let second_row_hints = vec![ + PickerFooterHint { + key: "ctrl+o", + wide_label: density_label.to_string(), + compact_label: density_compact_label.to_string(), + priority: 3, + }, + PickerFooterHint { + key: "ctrl+t", + wide_label: String::from("transcript"), + compact_label: String::from("preview"), + priority: 4, + }, + PickerFooterHint { + key: "ctrl+e", + wide_label: String::from("expand"), + compact_label: String::from("exp"), + priority: 6, + }, + PickerFooterHint { + key: "↑/↓", + wide_label: String::from("browse"), + compact_label: String::from("browse"), + priority: 5, + }, + ]; + + vec![ + hint_line_for_row(&first_row_hints, width), + hint_line_for_row(&second_row_hints, width), + ] +} + +fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> { + if width >= FOOTER_COMPACT_BREAKPOINT + && let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width) + { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) { + return line; + } + + let mut retained = (0..hints.len()).collect::>(); + retained.sort_by_key(|idx| hints[*idx].priority); + for retain_count in (1..=retained.len()).rev() { + let mut candidate_indices = retained[..retain_count].to_vec(); + candidate_indices.sort_unstable(); + let candidate = candidate_indices + .iter() + .map(|idx| &hints[*idx]) + .collect::>(); + if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) { + return line; + } + } + Line::default() +} + +fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let message = "Loading transcript…"; + let message_width = UnicodeWidthStr::width(message) as u16; + let overlay_width = if area.width >= message_width.saturating_add(10) { + message_width + 10 + } else { + area.width + }; + let overlay_height = if area.height >= 3 { 3 } else { 1 }; + let overlay = Rect::new( + area.x + area.width.saturating_sub(overlay_width) / 2, + area.y + area.height.saturating_sub(overlay_height) / 2, + overlay_width, + overlay_height, + ); + let style = transcript_loading_overlay_style(); + for y in overlay.y..overlay.bottom() { + for x in overlay.x..overlay.right() { + frame.buffer[(x, y)].set_symbol(" ").set_style(style); + } + } + + let message = truncate_text(message, overlay.width as usize); + let message_width = UnicodeWidthStr::width(message.as_str()) as u16; + let line = Rect::new( + overlay.x + overlay.width.saturating_sub(message_width) / 2, + overlay.y + overlay.height / 2, + message_width.min(overlay.width), + 1, + ); + frame.render_widget_ref(Line::from(message.bold()), line); +} + +fn transcript_loading_overlay_style() -> Style { + let Some(bg) = default_bg() else { + return Style::default().bg(Color::DarkGray); + }; + let (overlay, alpha) = if is_light(bg) { + ((0, 0, 0), 0.08) + } else { + ((255, 255, 255), 0.14) + }; + Style::default().bg(best_color(blend(overlay, bg, alpha))) +} + +#[derive(Clone, Copy)] +enum FooterHintLabelMode { + Wide, + Compact, + KeyOnly, +} + +fn fit_footer_hints( + hints: &[PickerFooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let hint_refs = hints.iter().collect::>(); + fit_footer_hint_refs(&hint_refs, mode, width) +} + +fn fit_footer_hint_refs( + hints: &[&PickerFooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let gap_width = FOOTER_HINT_GAP; + if footer_hints_width(hints, mode, gap_width) > width as usize { + return None; + } + + let mut spans = vec![ + " ".repeat(FOOTER_HINT_LEFT_PADDING) + .set_style(footer_hint_label_style()), + ]; + for (idx, hint) in hints.iter().enumerate() { + if idx > 0 { + spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style())); + } + spans.push(hint.key.set_style(footer_hint_key_style())); + let label = match mode { + FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()), + FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()), + FooterHintLabelMode::KeyOnly => None, + }; + if let Some(label) = label { + spans.push(" ".set_style(footer_hint_label_style())); + spans.push(label.to_string().set_style(footer_hint_label_style())); + } + } + Some(spans.into()) +} + +fn footer_hint_key_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Black) + } else { + Style::default() + } +} + +fn footer_hint_label_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray) + } else { + Style::default().dim() + } +} + +fn footer_hints_width( + hints: &[&PickerFooterHint], + mode: FooterHintLabelMode, + gap_width: usize, +) -> usize { + FOOTER_HINT_LEFT_PADDING + + hints + .iter() + .enumerate() + .map(|(idx, hint)| { + let label_width = match mode { + FooterHintLabelMode::Wide => { + 1 + UnicodeWidthStr::width(hint.wide_label.as_str()) + } + FooterHintLabelMode::Compact => { + 1 + UnicodeWidthStr::width(hint.compact_label.as_str()) + } + FooterHintLabelMode::KeyOnly => 0, + }; + let hint_width = UnicodeWidthStr::width(hint.key) + label_width; + if idx == 0 { + hint_width + } else { + hint_width + gap_width + } + }) + .sum::() +} + +fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) { if area.height == 0 { return; } + Clear.render(area, frame.buffer); let rows = &state.filtered_rows; if rows.is_empty() { @@ -1121,120 +2445,742 @@ fn render_list( return; } - let capacity = area.height as usize; - let start = state.scroll_top.min(rows.len().saturating_sub(1)); - let end = rows.len().min(start + capacity); - let labels = &metrics.labels; - let mut y = area.y; - - let visibility = column_visibility(area.width, metrics, state.sort_key); - let max_created_width = metrics.max_created_width; - let max_updated_width = metrics.max_updated_width; - let max_branch_width = metrics.max_branch_width; - let max_cwd_width = metrics.max_cwd_width; - - for (idx, (row, (created_label, updated_label, branch_label, cwd_label))) in rows[start..end] - .iter() - .zip(labels[start..end].iter()) - .enumerate() - { - let is_sel = start + idx == state.selected; - let marker = if is_sel { "> ".bold() } else { " ".into() }; - let marker_width = 2usize; - let created_span = if visibility.show_created { - Some(Span::from(format!("{created_label: = vec![marker]; - if let Some(created) = created_span { - spans.push(created); - spans.push(" ".into()); - } - if let Some(updated) = updated_span { - spans.push(updated); - spans.push(" ".into()); - } - if let Some(branch) = branch_span { - spans.push(branch); - spans.push(" ".into()); - } - if let Some(cwd) = cwd_span { - spans.push(cwd); - spans.push(" ".into()); - } - if add_leading_gap { - spans.push(" ".into()); - } - spans.push(preview.into()); - - let line: Line = spans.into(); - let rect = Rect::new(area.x, y, area.width, 1); - frame.render_widget_ref(line, rect); - y = y.saturating_add(1); + let show_more_above = state.has_more_above(); + let show_more_below = state.has_more_below(area.height as usize); + let content_area = Rect::new( + area.x, + area.y.saturating_add(u16::from(show_more_above)), + area.width, + area.height + .saturating_sub(u16::from(show_more_above)) + .saturating_sub(u16::from(show_more_below)), + ); + if show_more_above { + frame.render_widget_ref( + more_line("↑ more"), + Rect::new(area.x, area.y, area.width, 1), + ); } - if state.pagination.loading.is_pending() && y < area.y.saturating_add(area.height) { + let start = state.scroll_top.min(rows.len().saturating_sub(1)); + let mut y = content_area.y; + for (idx, row) in rows[start..].iter().enumerate() { + if y >= content_area.y.saturating_add(content_area.height) { + break; + } + let row_idx = start + idx; + let is_selected = row_idx == state.selected; + let is_expanded = + is_selected && row.thread_id.is_some() && state.expanded_thread_id == row.thread_id; + let is_zebra = row_idx.is_multiple_of(2); + for line in render_session_lines(row, state, is_selected, is_expanded, is_zebra, area.width) + { + if y >= content_area.y.saturating_add(content_area.height) { + break; + } + frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1)); + y = y.saturating_add(1); + } + if state.density == SessionListDensity::Comfortable + && y < content_area.y.saturating_add(content_area.height) + && start + idx + 1 < rows.len() + { + y = y.saturating_add(1); + } + } + + if state.pagination.loading.is_pending() + && y < content_area.y.saturating_add(content_area.height) + { let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into(); let rect = Rect::new(area.x, y, area.width, 1); frame.render_widget_ref(loading_line, rect); } + if show_more_below { + let label = if state.pagination.loading.is_pending() { + "↓ loading more" + } else { + "↓ more" + }; + frame.render_widget_ref( + more_line(label), + Rect::new( + area.x, + area.y.saturating_add(area.height.saturating_sub(1)), + area.width, + 1, + ), + ); + } +} + +fn more_line(label: &'static str) -> Line<'static> { + vec![label.dim()].into() +} + +fn render_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + match state.density { + SessionListDensity::Comfortable => { + render_comfortable_session_lines(row, state, is_selected, is_expanded, is_zebra, width) + } + SessionListDensity::Dense => { + render_dense_session_lines(row, state, is_selected, is_expanded, is_zebra, width) + } + } +} + +fn render_comfortable_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + let marker = selection_marker(is_selected, is_expanded); + let title = truncate_text(row.display_preview(), width.saturating_sub(2) as usize); + let title = if is_selected { + selected_session_title_span(title) + } else { + title.into() + }; + let title_line = Line::from(vec![marker, title]); + let mut lines = vec![title_line]; + let row_style = if is_selected { + Some(dense_selected_style()) + } else if is_zebra { + Some(dense_zebra_style()) + } else { + None + }; + if let Some(style) = row_style { + lines = apply_session_row_background(lines, style, width); + } + if is_expanded { + lines.extend(render_transcript_preview_lines(row, state, width)); + return lines; + } + + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let created = format_relative_time(reference, row.created_at); + let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); + let branch = row.git_branch.as_deref(); + let cwd = row + .cwd + .as_ref() + .map(|path| format_directory_display(path, /*max_width*/ None)); + let footer_lines = render_footer_lines( + state.sort_key, + &created, + &updated, + branch, + cwd.as_deref(), + state.filter_mode == SessionFilterMode::All, + width, + ); + if let Some(style) = row_style { + lines.extend(apply_session_row_background(footer_lines, style, width)); + } else { + lines.extend(footer_lines); + } + lines +} + +fn apply_session_row_background( + lines: Vec>, + style: Style, + width: u16, +) -> Vec> { + lines + .into_iter() + .map(|line| apply_line_background(line, style, width)) + .collect() +} + +fn apply_line_background(mut line: Line<'static>, style: Style, width: u16) -> Line<'static> { + let padding = (width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans.push(" ".repeat(padding).set_style(style)); + } + line.style = line.style.patch(style); + for span in &mut line.spans { + span.style = span.style.patch(style); + } + line +} + +fn render_dense_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + let marker = selection_marker(is_selected, is_expanded); + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let created = format_relative_time(reference, row.created_at); + let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); + let date = match state.sort_key { + ThreadSortKey::CreatedAt => created, + ThreadSortKey::UpdatedAt => updated, + }; + let mut lines = vec![dense_summary_line(DenseSummaryInput { + marker, + date: &date, + title: row.display_preview(), + is_selected, + is_zebra, + width, + })]; + if is_expanded { + lines.extend(render_transcript_preview_lines(row, state, width)); + } + lines +} + +struct DenseSummaryInput<'a> { + marker: Span<'static>, + date: &'a str, + title: &'a str, + is_selected: bool, + is_zebra: bool, + width: u16, +} + +fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> { + let marker_width = input.marker.width(); + let available = (input.width as usize).saturating_sub(marker_width); + let columns = dense_columns(available); + let title = if input.is_selected { + selected_session_title_span(dense_column_text(input.title, columns.title_width)) + } else { + dense_column_text(input.title, columns.title_width).into() + }; + + let spans = vec![ + input.marker, + dense_column_text(input.date, columns.date_width).dim(), + title, + ]; + let mut line = Line::from(spans); + if input.is_selected { + let padding = (input.width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans + .push(" ".repeat(padding).set_style(dense_selected_style())); + } + line = line.style(dense_selected_style()); + } else if input.is_zebra { + let padding = (input.width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans + .push(" ".repeat(padding).set_style(dense_zebra_style())); + } + line = line.style(dense_zebra_style()); + } + line +} + +struct DenseColumns { + date_width: usize, + title_width: usize, +} + +fn dense_columns(width: usize) -> DenseColumns { + let date_width = SESSION_META_DATE_WIDTH; + DenseColumns { + date_width, + title_width: width.saturating_sub(date_width), + } +} + +fn dense_zebra_style() -> Style { + dense_row_background_style(/*selected*/ false) +} + +fn dense_selected_style() -> Style { + selected_session_style().patch(dense_row_background_style(/*selected*/ true)) +} + +fn dense_row_background_style(selected: bool) -> Style { + let Some(bg) = default_bg() else { + return Style::default(); + }; + let (overlay, alpha) = if is_light(bg) { + ((0, 0, 0), if selected { 0.12 } else { 0.04 }) + } else { + ((255, 255, 255), if selected { 0.12 } else { 0.055 }) + }; + Style::default().bg(best_color(blend(overlay, bg, alpha))) +} + +fn dense_column_text(text: &str, width: usize) -> String { + let text = truncate_text(text, width.saturating_sub(1)); + let padding = width.saturating_sub(UnicodeWidthStr::width(text.as_str())); + format!("{text}{}", " ".repeat(padding)) +} + +fn selection_marker(is_selected: bool, is_expanded: bool) -> Span<'static> { + match (is_selected, is_expanded) { + (true, true) => "⌄ ".set_style(selected_session_style().bold()), + (true, false) => "❯ ".set_style(selected_session_style().bold()), + (false, _) => " ".into(), + } +} + +fn selected_session_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::Yellow) + } +} + +fn selected_session_title_span(title: String) -> Span<'static> { + title.set_style(selected_session_style()) +} + +fn render_footer_lines( + sort_key: ThreadSortKey, + created: &str, + updated: &str, + branch: Option<&str>, + cwd: Option<&str>, + show_cwd: bool, + width: u16, +) -> Vec> { + let date = match sort_key { + ThreadSortKey::CreatedAt => created, + ThreadSortKey::UpdatedAt => updated, + }; + let mut parts = vec![FooterPart::Date(date.to_string())]; + if show_cwd { + parts.push(FooterPart::Cwd(cwd.map(str::to_string))); + } + parts.push(FooterPart::Branch(branch.map(str::to_string))); + pack_footer_parts(parts, width) +} + +enum FooterPart { + Date(String), + Branch(Option), + Cwd(Option), +} + +impl FooterPart { + fn text(&self) -> &str { + match self { + FooterPart::Date(text) => text, + FooterPart::Branch(Some(text)) | FooterPart::Cwd(Some(text)) => text, + FooterPart::Branch(None) => "no branch", + FooterPart::Cwd(None) => "no cwd", + } + } + + fn prefix(&self) -> Option<&'static str> { + match self { + FooterPart::Date(_) => None, + FooterPart::Branch(_) => Some(SESSION_META_BRANCH_ICON), + FooterPart::Cwd(_) => Some(SESSION_META_CWD_ICON), + } + } +} + +fn pack_footer_parts(parts: Vec, width: u16) -> Vec> { + let available_width = width as usize; + if available_width <= SESSION_META_INDENT_WIDTH { + return Vec::new(); + } + let cwd_width = cwd_column_width(available_width); + let all_parts_width = footer_parts_width(&parts, cwd_width); + if all_parts_width <= available_width { + return vec![footer_line(parts, available_width, cwd_width)]; + } + + let mut lines = Vec::with_capacity(parts.len()); + let mut current_parts = Vec::new(); + for part in parts { + let mut candidate_parts = std::mem::take(&mut current_parts); + candidate_parts.push(part); + if candidate_parts.len() > 1 + && footer_parts_width(&candidate_parts, cwd_width) > available_width + { + let previous_parts = candidate_parts + .drain(..candidate_parts.len().saturating_sub(1)) + .collect(); + lines.push(footer_line(previous_parts, available_width, cwd_width)); + } + current_parts = candidate_parts; + } + if !current_parts.is_empty() { + lines.push(footer_line(current_parts, available_width, cwd_width)); + } + lines +} + +fn cwd_column_width(width: usize) -> usize { + let available = width.saturating_sub( + SESSION_META_INDENT_WIDTH + SESSION_META_DATE_WIDTH + 2 * SESSION_META_FIELD_GAP_WIDTH, + ); + (available / 2).clamp(SESSION_META_MIN_CWD_WIDTH, SESSION_META_MAX_CWD_WIDTH) +} + +fn footer_parts_width(parts: &[FooterPart], cwd_width: usize) -> usize { + let content_width: usize = parts + .iter() + .enumerate() + .map(|(idx, part)| footer_part_width(part, idx + 1 < parts.len(), cwd_width)) + .sum(); + SESSION_META_INDENT_WIDTH + content_width +} + +fn footer_part_width(part: &FooterPart, padded: bool, cwd_width: usize) -> usize { + let prefix_width = part.prefix().map_or(0, UnicodeWidthStr::width); + let prefix_gap_width = usize::from(part.prefix().is_some() && !part.text().is_empty()); + let text_width = UnicodeWidthStr::width(part.text()); + let actual_width = prefix_width + prefix_gap_width + text_width; + match part { + FooterPart::Date(_) if padded => SESSION_META_DATE_WIDTH.max(actual_width), + FooterPart::Cwd(_) if padded => cwd_width, + _ => actual_width, + } +} + +fn footer_line(parts: Vec, width: usize, cwd_width: usize) -> Line<'static> { + let mut spans: Vec> = vec![" ".into()]; + let mut remaining_width = width.saturating_sub(SESSION_META_INDENT_WIDTH); + let part_count = parts.len(); + for (idx, part) in parts.into_iter().enumerate() { + if idx > 0 { + let gap_width = SESSION_META_FIELD_GAP_WIDTH.min(remaining_width); + if gap_width > 0 { + spans.push(" ".repeat(gap_width).dim()); + remaining_width = remaining_width.saturating_sub(gap_width); + } + } + let padded = idx + 1 < part_count; + let target_width = match part { + FooterPart::Date(_) if padded => Some(SESSION_META_DATE_WIDTH), + FooterPart::Cwd(_) if padded => Some(cwd_width), + FooterPart::Date(_) | FooterPart::Branch(_) | FooterPart::Cwd(_) => None, + }; + let used_width = push_footer_part(&mut spans, part, target_width, remaining_width); + remaining_width = remaining_width.saturating_sub(used_width); + if let Some(target_width) = target_width { + let padding = target_width.saturating_sub(used_width); + if padding > 0 { + spans.push(" ".repeat(padding).dim()); + remaining_width = remaining_width.saturating_sub(padding); + } + } + } + spans.into() +} + +fn push_footer_part( + spans: &mut Vec>, + part: FooterPart, + target_width: Option, + available_width: usize, +) -> usize { + let text = part.text().to_string(); + let Some(prefix) = part.prefix() else { + let text = truncate_text(&text, available_width); + let width = UnicodeWidthStr::width(text.as_str()); + spans.push(text.dim()); + return width; + }; + + let prefix_width = UnicodeWidthStr::width(prefix); + if available_width <= prefix_width { + let prefix = truncate_text(prefix, available_width); + let width = UnicodeWidthStr::width(prefix.as_str()); + spans.push(prefix.dim()); + return width; + } + + spans.push(prefix.dim()); + let mut used_width = prefix_width; + if !text.is_empty() && used_width < available_width { + spans.push(" ".dim()); + used_width += 1; + } + let text_width = target_width + .unwrap_or(available_width) + .saturating_sub(used_width) + .min(available_width.saturating_sub(used_width)); + let text = truncate_text(&text, text_width); + let rendered_text_width = UnicodeWidthStr::width(text.as_str()); + match part { + FooterPart::Branch(None) | FooterPart::Cwd(None) => spans.push(text.dim().italic()), + _ => spans.push(text.dim()), + } + used_width + rendered_text_width +} + +fn render_transcript_preview_lines( + row: &Row, + state: &PickerState, + width: u16, +) -> Vec> { + let mut details = render_expanded_session_details(row, state, width); + let Some(thread_id) = row.thread_id else { + return details; + }; + let preview_lines = match state.transcript_previews.get(&thread_id) { + Some(TranscriptPreviewState::Loading) => { + vec![vec![" │ ".dim(), "Loading recent transcript...".italic().dim()].into()] + } + Some(TranscriptPreviewState::Failed) => vec![ + vec![ + " │ ".dim(), + "Could not load transcript preview".italic().red(), + ] + .into(), + ], + Some(TranscriptPreviewState::Loaded(lines)) => { + render_conversation_preview_lines(lines, width) + } + None => Vec::new(), + }; + details.extend(preview_lines); + details +} + +fn render_expanded_session_details( + row: &Row, + state: &PickerState, + width: u16, +) -> Vec> { + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let session = row + .thread_name + .as_deref() + .map(str::to_string) + .or_else(|| row.thread_id.map(|thread_id| thread_id.to_string())) + .unwrap_or_else(|| "-".to_string()); + let directory = row + .cwd + .as_ref() + .map(|path| format_directory_display(path, /*max_width*/ None)) + .unwrap_or_else(|| "-".to_string()); + let branch = row + .git_branch + .as_ref() + .map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}")) + .unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch")); + + vec![ + expanded_detail_line("Session:", &session, width), + expanded_time_detail_line("Created:", reference, row.created_at, width), + expanded_time_detail_line( + "Updated:", + reference, + row.updated_at.or(row.created_at), + width, + ), + expanded_detail_line("Directory:", &directory, width), + expanded_detail_line("Branch:", &branch, width), + vec![" │".dim()].into(), + vec![" │ ".dim(), "Conversation:".dim()].into(), + ] +} + +fn render_conversation_preview_lines( + lines: &[TranscriptPreviewLine], + width: u16, +) -> Vec> { + if lines.is_empty() { + return vec![ + vec![ + " └ ".dim(), + "No transcript preview available".italic().dim(), + ] + .into(), + ]; + } + + let mut rendered = Vec::new(); + for line in lines { + rendered.extend(render_transcript_content_lines(line, width)); + } + let rendered_len = rendered.len(); + rendered + .into_iter() + .enumerate() + .map(|(idx, line)| { + let prefix = if idx + 1 == rendered_len { + " └ " + } else { + " │ " + }; + prefix_transcript_line(prefix, line) + }) + .collect() +} + +fn render_transcript_content_lines(line: &TranscriptPreviewLine, width: u16) -> Vec> { + let content_width = width.saturating_sub(4) as usize; + let lines = match line.speaker { + TranscriptPreviewSpeaker::User => vec![conversation_content_line( + Line::from(line.text.clone()), + conversation_user_style(), + )], + TranscriptPreviewSpeaker::Assistant => { + let mut lines = Vec::new(); + append_markdown( + &line.text, /*width*/ None, /*cwd*/ None, &mut lines, + ); + for line in &mut lines { + *line = conversation_content_line(line.clone(), conversation_assistant_style()); + } + lines + } + }; + adaptive_wrap_lines(lines, RtOptions::new(content_width.max(/*other*/ 1))) +} + +fn conversation_content_line(mut line: Line<'static>, style: Style) -> Line<'static> { + line.style = line.style.patch(style); + for span in &mut line.spans { + span.style = span.style.patch(style); + } + line +} + +fn prefix_transcript_line(prefix: &'static str, line: Line<'static>) -> Line<'static> { + let mut spans = vec![prefix.set_style(transcript_prefix_style(&line))]; + spans.extend(line.spans); + Line::from(spans).style(line.style) +} + +fn transcript_prefix_style(line: &Line<'_>) -> Style { + let style = line + .spans + .iter() + .find(|span| !span.content.trim().is_empty()) + .map(|span| line.style.patch(span.style)) + .unwrap_or(line.style); + connector_style_from_content(style) +} + +fn connector_style_from_content(style: Style) -> Style { + Style { + fg: style.fg, + bg: style.bg, + ..Style::default() + } +} + +fn conversation_assistant_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Gray) + } else { + Style::default().fg(Color::DarkGray) + } +} + +fn conversation_user_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray).italic() + } else { + Style::default().fg(Color::Gray).italic() + } +} + +fn expanded_detail_line(label: &'static str, value: &str, width: u16) -> Line<'static> { + const LABEL_WIDTH: usize = 10; + let prefix_width = 4; + let gap_width = 2; + let value_width = (width as usize) + .saturating_sub(prefix_width + LABEL_WIDTH + gap_width) + .max(1); + vec![ + " │ ".dim(), + format!("{label:, + ts: Option>, + width: u16, +) -> Line<'static> { + let Some(ts) = ts else { + return expanded_detail_line(label, "-", width); + }; + let value = format!( + "{} · {}", + format_relative_time_long(reference, ts), + format_timestamp(ts) + ); + expanded_detail_line(label, &value, width) +} + +fn format_relative_time(reference: DateTime, ts: Option>) -> String { + let Some(ts) = ts else { + return "-".to_string(); + }; + let seconds = (reference - ts).num_seconds().max(0); + if seconds == 0 { + return "now".to_string(); + } + if seconds < 60 { + return format!("{seconds}s ago"); + } + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m ago"); + } + let hours = minutes / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let days = hours / 24; + format!("{days}d ago") +} + +fn format_relative_time_long(reference: DateTime, ts: DateTime) -> String { + let seconds = (reference - ts).num_seconds().max(0); + if seconds == 0 { + return "now".to_string(); + } + if seconds < 60 { + return plural_time(seconds, "second"); + } + let minutes = seconds / 60; + if minutes < 60 { + return plural_time(minutes, "minute"); + } + let hours = minutes / 60; + if hours < 24 { + return plural_time(hours, "hour"); + } + plural_time(hours / 24, "day") +} + +fn plural_time(value: i64, unit: &str) -> String { + if value == 1 { + format!("1 {unit} ago") + } else { + format!("{value} {unit}s ago") + } +} + +fn format_timestamp(ts: DateTime) -> String { + ts.format("%Y-%m-%d %H:%M:%S").to_string() } fn render_empty_state_line(state: &PickerState) -> Line<'static> { @@ -1264,254 +3210,11 @@ fn render_empty_state_line(state: &PickerState) -> Line<'static> { vec!["No sessions yet".italic().dim()].into() } -fn human_time_ago(ts: DateTime, reference_now: DateTime) -> String { - let delta = reference_now - ts; - let secs = delta.num_seconds(); - if secs < 60 { - let n = secs.max(0); - if n == 1 { - format!("{n} second ago") - } else { - format!("{n} seconds ago") - } - } else if secs < 60 * 60 { - let m = secs / 60; - if m == 1 { - format!("{m} minute ago") - } else { - format!("{m} minutes ago") - } - } else if secs < 60 * 60 * 24 { - let h = secs / 3600; - if h == 1 { - format!("{h} hour ago") - } else { - format!("{h} hours ago") - } - } else { - let d = secs / (60 * 60 * 24); - if d == 1 { - format!("{d} day ago") - } else { - format!("{d} days ago") - } - } -} - -fn format_updated_label_at(row: &Row, reference_now: DateTime) -> String { - match (row.updated_at, row.created_at) { - (Some(updated), _) => human_time_ago(updated, reference_now), - (None, Some(created)) => human_time_ago(created, reference_now), - (None, None) => "-".to_string(), - } -} - -fn format_created_label_at(row: &Row, reference_now: DateTime) -> String { - match row.created_at { - Some(created) => human_time_ago(created, reference_now), - None => "-".to_string(), - } -} - -fn render_column_headers( - frame: &mut crate::custom_terminal::Frame, - area: Rect, - metrics: &ColumnMetrics, - sort_key: ThreadSortKey, -) { - if area.height == 0 { - return; - } - - let mut spans: Vec = vec![" ".into()]; - let visibility = column_visibility(area.width, metrics, sort_key); - if visibility.show_created { - let label = format!( - "{text:, -} - -/// Determines which columns to render given available terminal width. -/// -/// When the terminal is narrow, only one timestamp column is shown (whichever -/// matches the current sort key). Branch and CWD are hidden if their max -/// widths are zero (no data to show). -#[derive(Debug, PartialEq, Eq)] -struct ColumnVisibility { - show_created: bool, - show_updated: bool, - show_branch: bool, - show_cwd: bool, -} - -fn calculate_column_metrics( - rows: &[Row], - include_cwd: bool, - reference_now: DateTime, -) -> ColumnMetrics { - fn right_elide(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - if max <= 1 { - return "…".to_string(); - } - let tail_len = max - 1; - let tail: String = s - .chars() - .rev() - .take(tail_len) - .collect::() - .chars() - .rev() - .collect(); - format!("…{tail}") - } - - let mut labels: Vec<(String, String, String, String)> = Vec::with_capacity(rows.len()); - let mut max_created_width = UnicodeWidthStr::width(CREATED_COLUMN_LABEL); - let mut max_updated_width = UnicodeWidthStr::width(UPDATED_COLUMN_LABEL); - let mut max_branch_width = UnicodeWidthStr::width("Branch"); - let mut max_cwd_width = if include_cwd { - UnicodeWidthStr::width("CWD") - } else { - 0 - }; - - for row in rows { - let created = format_created_label_at(row, reference_now); - let updated = format_updated_label_at(row, reference_now); - let branch_raw = row.git_branch.clone().unwrap_or_default(); - let branch = right_elide(&branch_raw, /*max*/ 24); - let cwd = if include_cwd { - let cwd_raw = row - .cwd - .as_ref() - .map(|p| display_path_for(p, std::path::Path::new("/"))) - .unwrap_or_default(); - right_elide(&cwd_raw, /*max*/ 24) - } else { - String::new() - }; - max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str())); - max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str())); - max_branch_width = max_branch_width.max(UnicodeWidthStr::width(branch.as_str())); - max_cwd_width = max_cwd_width.max(UnicodeWidthStr::width(cwd.as_str())); - labels.push((created, updated, branch, cwd)); - } - - ColumnMetrics { - max_created_width, - max_updated_width, - max_branch_width, - max_cwd_width, - labels, - } -} - -/// Computes which columns fit in the available width. -/// -/// The algorithm reserves at least `MIN_PREVIEW_WIDTH` characters for the -/// conversation preview. If both timestamp columns don't fit, only the one -/// matching the current sort key is shown. -fn column_visibility( - area_width: u16, - metrics: &ColumnMetrics, - sort_key: ThreadSortKey, -) -> ColumnVisibility { - const MIN_PREVIEW_WIDTH: usize = 10; - - let show_branch = metrics.max_branch_width > 0; - let show_cwd = metrics.max_cwd_width > 0; - - // Calculate remaining width after all optional columns. - let mut preview_width = area_width as usize; - preview_width = preview_width.saturating_sub(2); // marker - if metrics.max_created_width > 0 { - preview_width = preview_width.saturating_sub(metrics.max_created_width + 2); - } - if metrics.max_updated_width > 0 { - preview_width = preview_width.saturating_sub(metrics.max_updated_width + 2); - } - if show_branch { - preview_width = preview_width.saturating_sub(metrics.max_branch_width + 2); - } - if show_cwd { - preview_width = preview_width.saturating_sub(metrics.max_cwd_width + 2); - } - - // If preview would be too narrow, hide the non-active timestamp column. - let show_both = preview_width >= MIN_PREVIEW_WIDTH; - let show_created = if show_both { - metrics.max_created_width > 0 - } else { - sort_key == ThreadSortKey::CreatedAt - }; - let show_updated = if show_both { - metrics.max_updated_width > 0 - } else { - sort_key == ThreadSortKey::UpdatedAt - }; - - ColumnVisibility { - show_created, - show_updated, - show_branch, - show_cwd, - } -} - #[cfg(test)] mod tests { use super::*; use chrono::Duration; + use codex_config::CONFIG_TOML_FILE; use codex_protocol::ThreadId; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -1525,6 +3228,7 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; + use tempfile::tempdir; fn page( rows: Vec, @@ -1540,6 +3244,14 @@ mod tests { } } + fn page_only_loader(loader: impl Fn(PageLoadRequest) + Send + Sync + 'static) -> PickerLoader { + Arc::new(move |request| { + if let PickerLoadRequest::Page(request) = request { + loader(request); + } + }) + } + fn make_row(path: &str, ts: &str, preview: &str) -> Row { let timestamp = parse_timestamp_str(ts).expect("timestamp should parse"); Row { @@ -1554,6 +3266,38 @@ mod tests { } } + fn footer_lines_text(state: &PickerState, width: u16) -> String { + footer_hint_lines(state, width) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") + } + + fn footer_snapshot(state: &PickerState, width: u16, list_height: u16) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let backend = VT100Backend::new(width, /*height*/ 4); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 4)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_picker_footer(&mut frame, area, state, list_height); + } + terminal.flush().expect("flush"); + + terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + } + #[test] fn row_display_preview_prefers_thread_name() { let row = Row { @@ -1592,6 +3336,213 @@ mod tests { ); } + #[test] + fn row_search_matches_metadata_fields() { + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("first message"), + thread_id: Some(thread_id), + thread_name: Some(String::from("My session")), + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/tmp/codex-session-picker")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + + assert!(row.matches_query("session-picker")); + assert!(row.matches_query("fcoury")); + assert!(row.matches_query(&thread_id.to_string()[..8])); + } + + #[test] + fn relative_time_formats_zero_seconds_as_now() { + let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z") + .expect("valid timestamp") + .with_timezone(&Utc); + + assert_eq!(format_relative_time(reference, Some(reference)), "now"); + assert_eq!( + format_relative_time(reference, Some(reference - Duration::seconds(1))), + "1s ago" + ); + } + + #[test] + fn long_relative_time_uses_words() { + let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z") + .expect("valid timestamp") + .with_timezone(&Utc); + + assert_eq!(format_relative_time_long(reference, reference), "now"); + assert_eq!( + format_relative_time_long(reference, reference - Duration::minutes(20)), + "20 minutes ago" + ); + assert_eq!( + format_relative_time_long(reference, reference - Duration::hours(1)), + "1 hour ago" + ); + } + + #[test] + fn expanded_session_details_include_metadata() { + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = parse_timestamp_str("2026-05-02T14:48:19Z"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("first message"), + thread_id: Some(thread_id), + thread_name: Some(String::from("feat(tui): add raw scrollback mode")), + created_at: parse_timestamp_str("2026-05-02T14:31:08Z"), + updated_at: parse_timestamp_str("2026-05-02T14:48:19Z"), + cwd: Some(PathBuf::from("/Users/felipe.coury/code/codex")), + git_branch: Some(String::from("codex/raw-scrollback-mode")), + }; + + let rendered = render_expanded_session_details(&row, &state, /*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let expected_directory = + format_directory_display(row.cwd.as_deref().expect("cwd"), /*max_width*/ None); + + assert!(rendered.contains("Session: feat(tui): add raw scrollback mode")); + assert!(rendered.contains("Created: 17 minutes ago · 2026-05-02 14:31:08")); + assert!(rendered.contains("Updated: now · 2026-05-02 14:48:19")); + assert!(rendered.contains(&format!("Directory: {expected_directory}"))); + assert!(rendered.contains("Branch:  codex/raw-scrollback-mode")); + assert!(rendered.contains("Conversation:")); + } + + #[test] + fn footer_prioritizes_active_sort_timestamp() { + let updated = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "3h ago", + Some("main"), + Some("tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + let created = render_footer_lines( + ThreadSortKey::CreatedAt, + "5h ago", + "3h ago", + Some("main"), + Some("tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(updated.len(), 1); + assert_eq!(created.len(), 1); + assert!(updated[0].to_string().starts_with(" 3h ago")); + assert!(created[0].to_string().starts_with(" 5h ago")); + assert!(!updated[0].to_string().contains("created 5h ago")); + assert!(!created[0].to_string().contains("updated 3h ago")); + assert_metadata_order(&updated[0], "⌁ tmp/codex", " main"); + assert_metadata_order(&created[0], "⌁ tmp/codex", " main"); + } + + #[test] + fn footer_marks_missing_branch() { + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "3h ago", + /*branch*/ None, + Some("/tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let rendered = footer[0].to_string(); + assert!(rendered.contains("⌁ /tmp/codex")); + assert!(rendered.contains(" no branch")); + assert_metadata_order(&footer[0], "⌁ /tmp/codex", " no branch"); + } + + #[test] + fn footer_branch_expands_when_line_has_room() { + let branch = "etraut/animations-false-improvements"; + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some(branch), + Some("~/code/codex.etraut-animations-false-improvements/codex-rs"), + /*show_cwd*/ true, + /*width*/ 140, + ); + + assert_eq!(footer.len(), 1); + assert!(footer[0].to_string().contains(branch)); + } + + #[test] + fn footer_cwd_truncates_to_responsive_column() { + let cwd = "~/code/codex.owner-extremely-long-worktree-name-that-needs-truncating/codex-rs"; + let branch = "owner/branch"; + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some(branch), + Some(cwd), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let footer = footer[0].to_string(); + assert!(!footer.contains(cwd)); + assert!(footer.contains("⌁ ~/code/codex.")); + assert!(footer.contains("...")); + assert!(footer.contains(" owner/branch")); + } + + #[test] + fn footer_omits_cwd_when_hidden() { + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some("owner/branch"), + Some("~/code/codex.owner-worktree/codex-rs"), + /*show_cwd*/ false, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let footer = footer[0].to_string(); + assert!(footer.contains("4h ago")); + assert!(footer.contains(" owner/branch")); + assert!(!footer.contains("⌁")); + assert!(!footer.contains("~/code")); + } + + fn assert_metadata_order(line: &Line<'_>, first: &str, second: &str) { + let rendered = line.to_string(); + let first_index = rendered.find(first).expect("first metadata item"); + let second_index = rendered.find(second).expect("second metadata item"); + assert!(first_index < second_index); + } + #[test] fn remote_thread_list_params_omit_provider_filter() { let params = thread_list_params( @@ -1629,9 +3580,49 @@ mod tests { assert_eq!(params.source_kinds, None); } + #[test] + fn remote_picker_sends_cwd_filter_without_local_post_filtering() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + let remote_cwd = Some(PathBuf::from("/srv/link-project")); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::Any, + /*show_all*/ false, + remote_cwd.clone(), + SessionPickerAction::Resume, + ); + state.local_filter_cwd = local_picker_cwd_filter(&remote_cwd, /*is_remote*/ true); + + state.start_initial_load(); + + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, remote_cwd); + } + + let row = Row { + path: None, + preview: String::from("remote session"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/srv/real-project")), + git_branch: None, + }; + + assert!(state.row_matches_filter(&row)); + } + #[test] fn remote_picker_does_not_filter_rows_by_local_cwd() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1658,10 +3649,8 @@ mod tests { fn resume_table_snapshot() { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; - use ratatui::layout::Constraint; - use ratatui::layout::Layout; - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1671,7 +3660,7 @@ mod tests { SessionPickerAction::Resume, ); - let now = Utc::now(); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); let rows = vec![ Row { path: Some(PathBuf::from("/tmp/a.jsonl")), @@ -1706,16 +3695,13 @@ mod tests { ]; state.all_rows = rows.clone(); state.filtered_rows = rows; - state.view_rows = Some(3); + state.relative_time_reference = Some(now); state.selected = 1; state.scroll_top = 0; - state.update_view_rows(/*rows*/ 3); - - state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now); + state.update_viewport(/*rows*/ 12, /*width*/ 80); let width: u16 = 80; - let height: u16 = 6; + let height: u16 = 12; let backend = VT100Backend::new(width, height); let mut terminal = Terminal::with_options(backend).expect("terminal"); terminal.set_viewport_area(Rect::new(0, 0, width, height)); @@ -1723,10 +3709,7 @@ mod tests { { let mut frame = terminal.get_frame(); let area = frame.area(); - let segments = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area); - render_column_headers(&mut frame, segments[0], &metrics, state.sort_key); - render_list(&mut frame, segments[1], &state, &metrics); + render_list(&mut frame, area, &state); } terminal.flush().expect("flush"); @@ -1739,7 +3722,7 @@ mod tests { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1760,7 +3743,7 @@ mod tests { { let mut frame = terminal.get_frame(); - let line = search_line(&state); + let line = search_line(&state, frame.area().width); frame.render_widget_ref(line, frame.area()); } terminal.flush().expect("flush"); @@ -1769,9 +3752,1443 @@ mod tests { assert_snapshot!("resume_picker_search_error", snapshot); } + #[test] + fn hint_line_switches_esc_label_for_search_mode() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc start new")); + + state.query = String::from("picker"); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc clear search")); + } + + #[test] + fn hint_line_labels_cancel_keys_as_exit_for_existing_session_resume_picker() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.launch_context = SessionPickerLaunchContext::ExistingSession; + + let wide = footer_lines_text(&state, /*width*/ 220); + assert!(wide.contains("esc exit")); + assert!(wide.contains("ctrl+c exit")); + + let compact = footer_lines_text(&state, /*width*/ 119); + assert!(compact.contains("esc exit")); + assert!(compact.contains("ctrl+c exit")); + + state.query = String::from("picker"); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc clear search")); + } + + #[test] + fn hint_line_switches_density_label() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o dense view")); + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+t transcript")); + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+e expand")); + + state.density = SessionListDensity::Dense; + + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o comfortable view")); + } + + #[test] + fn hint_line_compacts_on_narrow_width() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let rendered = footer_lines_text(&state, /*width*/ 119); + + assert!(rendered.contains("esc new")); + assert!(rendered.contains("tab focus")); + assert!(rendered.contains("←/→ option")); + assert!(rendered.contains("ctrl+o dense")); + assert!(rendered.contains("ctrl+t preview")); + assert!(rendered.contains("ctrl+e exp")); + assert!(!rendered.contains("focus sort/filter")); + } + + #[test] + fn hint_line_snapshot_uses_distributed_wide_footer() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_snapshot!( + "resume_picker_footer_wide", + footer_snapshot(&state, /*width*/ 220, /*list_height*/ 20) + ); + } + + #[test] + fn hint_line_snapshot_uses_compact_footer() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("picker"); + state.density = SessionListDensity::Dense; + + assert_snapshot!( + "resume_picker_footer_compact", + footer_snapshot(&state, /*width*/ 96, /*list_height*/ 20) + ); + } + + #[test] + fn hint_line_prioritizes_keybinds_when_very_narrow() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + + let width = 38; + let lines = footer_hint_lines(&state, width); + let rendered = lines + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + + assert!(lines.iter().all(|line| line.width() <= width as usize)); + assert!(rendered.contains("enter")); + assert!(rendered.contains("esc")); + assert!(rendered.contains("ctrl+c")); + assert!(rendered.contains("ctrl+o")); + assert!(rendered.contains("ctrl+t")); + assert!(rendered.contains("ctrl+e")); + assert!(rendered.contains("↑/↓")); + } + + #[test] + fn hint_line_shows_loading_transcript_mode() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(ThreadId::new()); + + let rendered = footer_lines_text(&state, /*width*/ 80); + + assert!(rendered.contains("loading transcript")); + assert!(rendered.contains("ctrl+c quit")); + assert!(!rendered.contains("enter")); + } + + #[test] + fn picker_footer_percent_reports_scroll_progress() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + + state.scroll_top = 0; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 6), 0); + + state.scroll_top = state.filtered_rows.len() - 1; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 6), 100); + } + + #[test] + fn picker_footer_progress_label_shows_position_total_and_percent() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 2; + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 3 / 10 · 0% "); + assert!(!label.contains('-')); + } + + #[test] + fn picker_footer_progress_label_uses_known_count_when_more_pages_exist() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 2; + state.pagination.next_cursor = Some(PageCursor::AppServer(String::from("cursor-1"))); + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 3 / 10 · 0% "); + } + + #[test] + fn picker_footer_progress_label_freezes_percent_while_loading() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 9; + state.scroll_top = 9; + state.pagination.next_cursor = Some(PageCursor::AppServer(String::from("cursor-1"))); + state.pagination.loading = LoadingState::Pending(PendingLoad { + request_token: 1, + search_token: None, + }); + state.frozen_footer_percent = Some(37); + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 10 / 10… · 37% "); + } + + #[test] + fn picker_footer_percent_is_complete_when_not_scrollable() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_eq!(picker_footer_percent(&state, /*list_height*/ 20), 100); + + state.filtered_rows = vec![make_row( + "/tmp/1.jsonl", + "2026-05-02T12:00:00Z", + "single row", + )]; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 20), 100); + } + + #[tokio::test] + async fn ctrl_o_toggles_density_without_typing_into_search() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("pick"); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert_eq!(state.query, "pick"); + } + + #[tokio::test] + async fn ctrl_t_requests_selected_session_transcript() { + let thread_id = ThreadId::new(); + 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::Transcript { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Comfortable); + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert!(matches!( + state.transcript_cells.get(&thread_id), + Some(SessionTranscriptState::Loading) + )); + } + + #[tokio::test] + async fn transcript_loading_consumes_picker_input() { + let loader = page_only_loader(|_| {}); + let thread_id = ThreadId::new(); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![ + Row { + path: None, + preview: String::from("one"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + Row { + path: None, + preview: String::from("two"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + ]; + state.pending_transcript_open = Some(thread_id); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + + assert!(selection.is_none()); + assert_eq!(state.selected, 0); + assert_eq!(state.query, ""); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert!(selection.is_none()); + assert_eq!(state.query, ""); + } + + #[tokio::test] + async fn transcript_loading_still_allows_ctrl_c_exit() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(ThreadId::new()); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert!(matches!(selection, Some(SessionSelection::Exit))); + } + + #[test] + fn transcript_loading_overlay_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let thread_id = ThreadId::new(); + state.pending_transcript_open = Some(thread_id); + state.filtered_rows = vec![ + Row { + path: None, + preview: String::from("Find pending threads and emails"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + Row { + path: None, + preview: String::from("Plan raw scrollback mode"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + ]; + state.update_viewport(/*rows*/ 7, /*width*/ 80); + + let width: u16 = 80; + let height: u16 = 7; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + render_transcript_loading_overlay(&mut frame, area); + } + terminal.flush().expect("flush"); + + let snapshot = terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + assert_snapshot!("resume_picker_transcript_loading_overlay", snapshot); + } + + #[tokio::test] + async fn raw_ctrl_t_requests_selected_session_transcript() { + let thread_id = ThreadId::new(); + 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::Transcript { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{0014}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + } + + #[tokio::test] + async fn ctrl_t_on_row_without_thread_id_shows_inline_error() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("preview"), + thread_id: None, + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!( + state.inline_error.as_deref(), + Some("No transcript available for this session") + ); + } + + #[tokio::test] + async fn loaded_transcript_waits_for_loading_frame_before_opening_overlay() { + use crate::history_cell::PlainHistoryCell; + + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(thread_id); + let cells: TranscriptCells = + vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))]; + + state + .handle_background_event(BackgroundEvent::Transcript { + thread_id, + transcript: Ok(cells), + }) + .await + .unwrap(); + + assert!(state.overlay.is_none()); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert!(matches!( + state.transcript_cells.get(&thread_id), + Some(SessionTranscriptState::Loaded(_)) + )); + + assert!(state.note_transcript_loading_frame_drawn()); + state.open_pending_transcript_if_ready(); + + assert!(matches!(state.overlay, Some(Overlay::Transcript(_)))); + assert_eq!(state.pending_transcript_open, None); + } + + #[tokio::test] + async fn cached_transcript_still_shows_loading_frame_before_opening_overlay() { + use crate::history_cell::PlainHistoryCell; + + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + state.transcript_cells.insert( + thread_id, + SessionTranscriptState::Loaded(vec![Arc::new(PlainHistoryCell::new(vec![ + "transcript".into(), + ]))]), + ); + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert!(state.overlay.is_none()); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + + assert!(state.note_transcript_loading_frame_drawn()); + state.open_pending_transcript_if_ready(); + + assert!(matches!(state.overlay, Some(Overlay::Transcript(_)))); + assert_eq!(state.pending_transcript_open, None); + } + + #[tokio::test] + async fn ctrl_o_persists_density_preference() { + let tmp = tempdir().expect("tmpdir"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: tmp.path().to_path_buf(), + active_profile: None, + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!( + contents, + r#"[tui] +session_picker_view = "dense" +"# + ); + } + + #[tokio::test] + async fn ctrl_o_persists_density_preference_for_active_profile() { + let tmp = tempdir().expect("tmpdir"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: tmp.path().to_path_buf(), + active_profile: Some(String::from("work")), + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!( + contents, + r#"[profiles.work.tui] +session_picker_view = "dense" +"# + ); + } + + #[tokio::test] + async fn ctrl_o_keeps_toggled_density_when_persistence_fails() { + let tmp = tempdir().expect("tmpdir"); + let codex_home_file = tmp.path().join("codex-home-file"); + std::fs::write(&codex_home_file, "not a directory").expect("write codex home file"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: codex_home_file, + active_profile: None, + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert!( + state + .inline_error + .as_deref() + .is_some_and(|error| error.contains("Failed to save view mode")), + "expected persistence error, got {:?}", + state.inline_error + ); + } + + #[tokio::test] + async fn raw_ctrl_o_toggles_density_without_typing_into_search() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("pick"); + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{000f}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert_eq!(state.query, "pick"); + } + + #[tokio::test] + async fn space_appends_to_search_query() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state + .handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)) + .await + .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.query, "resize r"); + assert_eq!(state.expanded_thread_id, None); + } + + #[tokio::test] + async fn ctrl_e_toggles_selected_session_expansion() { + let thread_id = ThreadId::new(); + 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 { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, Some(thread_id)); + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + + state + .handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, None); + } + + #[tokio::test] + async fn raw_ctrl_e_toggles_selected_session_expansion() { + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{0005}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, Some(thread_id)); + } + + #[test] + fn search_line_renders_sort_and_filter_tabs() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + let width: u16 = 100; + let backend = VT100Backend::new(width, /*height*/ 1); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 1)); + + { + let mut frame = terminal.get_frame(); + let line = search_line(&state, frame.area().width); + frame.render_widget_ref(line, frame.area()); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_search_line_sort_filter_tabs", + terminal.backend().to_string() + ); + } + + #[test] + fn search_line_compacts_toolbar_on_narrow_width() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + let line = search_line(&state, /*width*/ 40).to_string(); + + assert!(line.contains("Filter:[Cwd]")); + assert!(line.contains("Sort:[Updated]")); + assert!(line.find("Filter:[Cwd]") < line.find("Sort:[Updated]")); + } + + fn dense_snapshot_row() -> Row { + Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from( + "Propose session picker redesign with enough title text to exercise truncation", + ), + thread_id: Some( + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"), + ), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs", + )), + git_branch: Some(String::from("fcoury/session-picker")), + } + } + + fn render_dense_row_snapshot( + show_all: bool, + filter_cwd: Option, + width: u16, + ) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let row = dense_snapshot_row(); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + show_all, + filter_cwd, + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let backend = VT100Backend::new(width, /*height*/ 3); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 3)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + terminal.backend().to_string() + } + + #[test] + fn dense_session_snapshot_omits_cwd_in_cwd_filter() { + assert_snapshot!( + "resume_picker_dense_cwd", + render_dense_row_snapshot( + /*show_all*/ false, + Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs" + )), + /*width*/ 100, + ) + ); + } + + #[test] + fn dense_session_snapshot_includes_cwd_in_all_filter() { + assert_snapshot!( + "resume_picker_dense_all", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 120, + ) + ); + } + + #[test] + fn dense_session_snapshot_auto_hides_cwd_when_narrow() { + assert_snapshot!( + "resume_picker_dense_all_auto_hidden_cwd", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 100, + ) + ); + } + + #[test] + fn dense_session_snapshot_forces_cwd_when_narrow() { + assert_snapshot!( + "resume_picker_dense_all_forced_cwd", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 48, + ) + ); + } + + #[test] + fn dense_session_snapshot_drops_metadata_when_narrow() { + assert_snapshot!( + "resume_picker_dense_narrow", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 48, + ) + ); + } + + #[test] + fn dense_session_line_prefers_thread_name_over_preview() { + let mut row = dense_snapshot_row(); + row.preview = String::from("Raw conversation preview"); + row.thread_name = Some(String::from("Named session")); + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let rendered = render_dense_session_lines( + &row, &state, /*is_selected*/ false, /*is_expanded*/ false, + /*is_zebra*/ false, /*width*/ 100, + ) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("Named session")); + assert!(!rendered.contains("Raw conversation preview")); + } + + #[test] + fn dense_selected_summary_line_uses_full_width_selection_style() { + let line = dense_summary_line(DenseSummaryInput { + marker: selection_marker(/*is_selected*/ true, /*is_expanded*/ false), + date: "15m ago", + title: "Selected dense row", + is_selected: true, + is_zebra: false, + width: 80, + }); + + assert_eq!(line.width(), 80); + assert_eq!(line.style.fg, selected_session_style().fg); + assert_eq!(line.spans[0].content, "❯ "); + } + + #[test] + fn dense_zebra_summary_line_uses_full_width_background() { + let line = dense_summary_line(DenseSummaryInput { + marker: selection_marker(/*is_selected*/ false, /*is_expanded*/ false), + date: "15m ago", + title: "Zebra dense row", + is_selected: false, + is_zebra: true, + width: 80, + }); + + assert_eq!(line.width(), 80); + assert_eq!(line.style.bg, dense_zebra_style().bg); + } + + #[test] + fn comfortable_zebra_lines_use_full_width_background() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = + Some(parse_timestamp_str("2026-05-02T12:00:00Z").expect("timestamp")); + let row = make_row( + "/tmp/a.jsonl", + "2026-05-02T11:45:00Z", + "Zebra comfortable row", + ); + + let lines = render_comfortable_session_lines( + &row, &state, /*is_selected*/ false, /*is_expanded*/ false, + /*is_zebra*/ true, /*width*/ 100, + ); + + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|line| line.width() == 100)); + assert!( + lines + .iter() + .all(|line| line.style.bg == dense_zebra_style().bg) + ); + } + + #[test] + fn dense_session_snapshot_uses_no_blank_lines_between_rows() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut first = dense_snapshot_row(); + first.preview = String::from("First dense row"); + let mut second = dense_snapshot_row(); + second.preview = String::from("Second dense row"); + second.git_branch = Some(String::from("fcoury/other-branch")); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs", + )), + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + state.all_rows = vec![first.clone(), second.clone()]; + state.filtered_rows = vec![first, second]; + state.selected = 1; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let backend = VT100Backend::new(/*width*/ 80, /*height*/ 2); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 80, 2)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_dense_no_blank_lines", + terminal.backend().to_string() + ); + } + + #[test] + fn expanded_session_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("Investigate picker expansion"), + thread_id: Some(thread_id), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from("/tmp/codex")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + state.expanded_thread_id = Some(thread_id); + state.transcript_previews.insert( + thread_id, + TranscriptPreviewState::Loaded(vec![ + TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::User, + text: String::from("Show me the recent transcript"), + }, + TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text: String::from("Here are the *last* few lines."), + }, + ]), + ); + + let width: u16 = 90; + let height: u16 = 11; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + let rendered = terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + + assert_snapshot!("resume_picker_expanded_session", rendered); + } + + #[test] + fn narrow_session_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("Investigate picker expansion"), + thread_id: Some( + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"), + ), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from("/tmp/codex")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let width: u16 = 58; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_narrow_session", + terminal.backend().to_string() + ); + } + + #[test] + fn session_list_more_indicators_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); + state.all_rows = (0..5) + .map(|idx| Row { + path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))), + preview: format!("item-{idx}"), + thread_id: None, + thread_name: None, + created_at: Some(now - Duration::hours(idx)), + updated_at: Some(now - Duration::minutes(idx * 5)), + cwd: None, + git_branch: None, + }) + .collect(); + state.filtered_rows = state.all_rows.clone(); + state.relative_time_reference = Some(now); + state.selected = 2; + state.scroll_top = 1; + state.update_viewport(/*rows*/ 6, /*width*/ 80); + + let width: u16 = 80; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_more_indicators", + terminal.backend().to_string() + ); + } + + #[test] + fn density_toggle_clears_stale_more_indicator() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); + state.all_rows = (0..4) + .map(|idx| Row { + path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))), + preview: format!("item-{idx}"), + thread_id: None, + thread_name: None, + created_at: Some(now - Duration::hours(idx)), + updated_at: Some(now - Duration::minutes(idx * 5)), + cwd: None, + git_branch: None, + }) + .collect(); + state.filtered_rows = state.all_rows.clone(); + state.relative_time_reference = Some(now); + + let width: u16 = 80; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + state.update_viewport(height as usize, width); + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + assert!(terminal.backend().to_string().contains("↓ more")); + + state.density = SessionListDensity::Dense; + state.update_viewport(height as usize, width); + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert!(!terminal.backend().to_string().contains("↓ more")); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1828,7 +5245,7 @@ mod tests { fn ensure_minimum_rows_prefetches_when_underfilled() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -1859,54 +5276,87 @@ mod tests { } #[test] - fn column_visibility_hides_extra_date_column_when_narrow() { - let metrics = ColumnMetrics { - max_created_width: 8, - max_updated_width: 12, - max_branch_width: 0, - max_cwd_width: 0, - labels: Vec::new(), - }; + fn ensure_minimum_rows_does_not_prefetch_when_comfortable_cards_fill_view() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); - let created = column_visibility(/*area_width*/ 30, &metrics, ThreadSortKey::CreatedAt); - assert_eq!( - created, - ColumnVisibility { - show_created: true, - show_updated: false, - show_branch: false, - show_cwd: false, - } + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, ); + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), + make_row("/tmp/c.jsonl", "2025-01-03T00:00:00Z", "three"), + make_row("/tmp/d.jsonl", "2025-01-04T00:00:00Z", "four"), + ], + Some("2025-01-05T00:00:00Z"), + /*num_scanned_files*/ 4, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 6, /*width*/ 80); - let updated = column_visibility(/*area_width*/ 30, &metrics, ThreadSortKey::UpdatedAt); - assert_eq!( - updated, - ColumnVisibility { - show_created: false, - show_updated: true, - show_branch: false, - show_cwd: false, - } - ); + state.ensure_minimum_rows_for_view(/*minimum_rows*/ 6); - let wide = column_visibility(/*area_width*/ 40, &metrics, ThreadSortKey::CreatedAt); - assert_eq!( - wide, - ColumnVisibility { - show_created: true, - show_updated: true, - show_branch: false, - show_cwd: false, - } + assert!(recorded_requests.lock().unwrap().is_empty()); + } + + #[test] + fn ensure_minimum_rows_still_prefetches_when_dense_rows_underfill_view() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, ); + state.density = SessionListDensity::Dense; + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), + ], + Some("2025-01-03T00:00:00Z"), + /*num_scanned_files*/ 2, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 10, /*width*/ 80); + + state.ensure_minimum_rows_for_view(/*minimum_rows*/ 10); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert!(guard[0].search_token.is_none()); + } + + #[test] + fn list_viewport_width_matches_rendered_list_inset() { + assert_eq!(list_viewport_width(/*width*/ 80), 76); + assert_eq!(list_viewport_width(/*width*/ 3), 0); } #[tokio::test] async fn toggle_sort_key_reloads_with_new_sort() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -1930,15 +5380,123 @@ mod tests { .handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .await .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); let guard = recorded_requests.lock().unwrap(); assert_eq!(guard.len(), 2); assert_eq!(guard[1].sort_key, ThreadSortKey::CreatedAt); } + #[tokio::test] + async fn default_filter_focus_arrows_reload_with_new_filter() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + state.start_initial_load(); + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, Some(PathBuf::from("/tmp/project"))); + } + + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[1].cwd_filter, None); + } + + #[tokio::test] + async fn all_filter_can_switch_back_to_cwd_when_cwd_candidate_exists() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + state.start_initial_load(); + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, None); + } + + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[1].cwd_filter, Some(PathBuf::from("/tmp/project"))); + } + + #[tokio::test] + async fn filter_stays_all_when_no_cwd_candidate_exists() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::Any, + /*show_all*/ false, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_eq!( + search_line(&state, /*width*/ 80) + .to_string() + .matches("Cwd") + .count(), + 0 + ); + + state.start_initial_load(); + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, None); + } + #[tokio::test] async fn page_navigation_uses_view_rows() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1961,7 +5519,7 @@ mod tests { items, /*next_cursor*/ None, /*num_scanned_files*/ 20, /*reached_scan_cap*/ false, )); - state.update_view_rows(/*rows*/ 5); + state.update_viewport(/*rows*/ 5, /*width*/ 80); assert_eq!(state.selected, 0); state @@ -1981,11 +5539,71 @@ mod tests { .await .unwrap(); assert_eq!(state.selected, 5); + + state + .handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 19); + + state + .handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 0); + } + + #[tokio::test] + async fn end_jumps_to_last_known_row_and_starts_loading_more() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let items = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.reset_pagination(); + state.ingest_page(page( + items, + Some("cursor-1"), + /*num_scanned_files*/ 10, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + state + .handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 9); + assert!(state.pagination.loading.is_pending()); + assert_eq!(recorded_requests.lock().unwrap().len(), 1); + assert_eq!( + picker_footer_progress_label(&state, /*list_height*/ 5, /*width*/ 80), + " 10 / 10… · 100% " + ); } #[tokio::test] async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2024,7 +5642,7 @@ mod tests { #[tokio::test] async fn enter_on_pathless_thread_uses_thread_id() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2066,6 +5684,7 @@ mod tests { let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("remote thread"), ephemeral: false, @@ -2077,6 +5696,7 @@ mod tests { cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -2091,9 +5711,226 @@ mod tests { assert_eq!(row.thread_name, Some(String::from("Named thread"))); } + #[test] + fn thread_to_transcript_cells_renders_core_message_types() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + session_id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: vec![ + ThreadItem::UserMessage { + id: String::from("user-1"), + content: vec![codex_app_server_protocol::UserInput::Text { + text: String::from("hello from user"), + text_elements: Vec::new(), + }], + }, + ThreadItem::AgentMessage { + id: String::from("agent-1"), + text: String::from("hello from assistant"), + phase: None, + memory_citation: None, + }, + ThreadItem::Plan { + id: String::from("plan-1"), + text: String::from("1. Do the thing"), + }, + ], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + }; + + let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("hello from user")); + assert!(rendered.contains("hello from assistant")); + assert!(rendered.contains("Proposed Plan")); + assert!(rendered.contains("Do the thing")); + } + + #[test] + fn thread_to_transcript_cells_hides_raw_reasoning_when_not_enabled() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + session_id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: vec![ThreadItem::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, + completed_at: None, + duration_ms: None, + }], + }; + + let hidden = thread_to_transcript_cells(&thread, RawReasoningVisibility::Hidden) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let visible = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(!hidden.contains("private raw chain of thought")); + assert!(visible.contains("private raw chain of thought")); + } + + #[test] + fn thread_to_transcript_cells_shows_raw_reasoning_over_summary_when_enabled() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + session_id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: vec![ThreadItem::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, + completed_at: None, + duration_ms: None, + }], + }; + + let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("raw reasoning content")); + assert!(!rendered.contains("public summary")); + } + #[tokio::test] - async fn up_at_bottom_does_not_scroll_when_visible() { - let loader: PageLoader = Arc::new(|_| {}); + async fn moving_to_last_card_scrolls_when_cards_exceed_viewport() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..3 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 3, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.scroll_top, 1); + + state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 2); + assert_eq!(state.scroll_top, 2); + } + + #[tokio::test] + async fn up_from_bottom_keeps_viewport_stable_when_card_remains_visible() { + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2116,28 +5953,102 @@ mod tests { items, /*next_cursor*/ None, /*num_scanned_files*/ 10, /*reached_scan_cap*/ false, )); - state.update_view_rows(/*rows*/ 5); + state.update_viewport(/*rows*/ 5, /*width*/ 80); state.selected = state.filtered_rows.len().saturating_sub(1); state.ensure_selected_visible(); let initial_top = state.scroll_top; - assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(5)); + assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(1)); state .handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) .await .unwrap(); - assert_eq!(state.scroll_top, initial_top); + assert_eq!(state.scroll_top, initial_top.saturating_sub(1)); assert_eq!(state.selected, state.filtered_rows.len().saturating_sub(2)); } + #[tokio::test] + async fn up_scrolls_only_after_crossing_top_edge() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..10 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 10, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + state.selected = 8; + state.scroll_top = 8; + + state + .handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 7); + assert_eq!(state.scroll_top, 7); + } + + #[test] + fn list_reports_more_rows_above_and_below() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..5 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 5, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + assert!(!state.has_more_above()); + assert!(state.has_more_below(/*viewport_height*/ 5)); + + state.scroll_top = 2; + + assert!(state.has_more_above()); + assert!(state.has_more_below(/*viewport_height*/ 5)); + } + #[tokio::test] async fn set_query_loads_until_match_and_respects_scan_cap() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -2170,7 +6081,7 @@ mod tests { }; state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: first_request.request_token, search_token: first_request.search_token, page: Ok(page( @@ -2192,7 +6103,7 @@ mod tests { assert!(state.filtered_rows.is_empty()); state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( @@ -2221,7 +6132,7 @@ mod tests { }; state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( @@ -2236,7 +6147,7 @@ mod tests { assert_eq!(recorded_requests.lock().unwrap().len(), 1); state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: active_request.request_token, search_token: active_request.search_token, page: Ok(page( @@ -2253,4 +6164,61 @@ mod tests { assert!(!state.search_state.is_active()); assert!(state.pagination.reached_scan_cap); } + + #[tokio::test] + async fn esc_with_empty_query_starts_fresh() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await + .expect("handle key"); + + assert!(matches!(selection, Some(SessionSelection::StartFresh))); + } + + #[tokio::test] + async fn esc_with_query_clears_search_and_preserves_selected_result() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/alpha.jsonl", "2025-01-03T00:00:00Z", "alpha"), + make_row("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta"), + ], + /*next_cursor*/ None, + /*num_scanned_files*/ 2, + /*reached_scan_cap*/ false, + )); + state.set_query(String::from("beta")); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await + .expect("handle key"); + + assert!(selection.is_none()); + assert!(state.query.is_empty()); + assert_eq!(state.filtered_rows.len(), 2); + assert_eq!( + state.filtered_rows[state.selected].path.as_deref(), + Some(Path::new("/tmp/beta.jsonl")) + ); + } } diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/resume_picker/transcript.rs new file mode 100644 index 0000000000..4fe75efe63 --- /dev/null +++ b/codex-rs/tui/src/resume_picker/transcript.rs @@ -0,0 +1,214 @@ +use std::sync::Arc; + +use crate::app_server_session::AppServerSession; +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::ReasoningSummaryCell; +use crate::history_cell::UserHistoryCell; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadItem; +use codex_protocol::ThreadId; +use codex_protocol::items::UserMessageItem; +use ratatui::style::Stylize as _; +use ratatui::text::Line; + +pub(crate) type TranscriptCells = Vec>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RawReasoningVisibility { + Hidden, + Visible, +} + +pub(crate) async fn load_session_transcript( + app_server: &mut AppServerSession, + thread_id: ThreadId, + raw_reasoning_visibility: RawReasoningVisibility, +) -> std::io::Result { + let thread = app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + .map_err(std::io::Error::other)?; + Ok(thread_to_transcript_cells( + &thread, + raw_reasoning_visibility, + )) +} + +pub(crate) fn thread_to_transcript_cells( + thread: &Thread, + raw_reasoning_visibility: RawReasoningVisibility, +) -> TranscriptCells { + let cwd = thread.cwd.as_path(); + let mut cells: TranscriptCells = Vec::new(); + for item in thread.turns.iter().flat_map(|turn| turn.items.iter()) { + match item { + ThreadItem::UserMessage { id, content } => { + let item = UserMessageItem { + id: id.clone(), + content: content + .iter() + .cloned() + .map(codex_app_server_protocol::UserInput::into_core) + .collect(), + }; + cells.push(Arc::new(UserHistoryCell { + message: item.message(), + text_elements: item.text_elements(), + local_image_paths: item.local_image_paths(), + remote_image_urls: item.image_urls(), + })); + } + ThreadItem::AgentMessage { text, .. } => { + if !text.trim().is_empty() { + cells.push(Arc::new(AgentMarkdownCell::new(text.clone(), cwd))); + } + } + ThreadItem::Plan { text, .. } => { + if !text.trim().is_empty() { + cells.push(Arc::new(crate::history_cell::new_proposed_plan( + text.clone(), + cwd, + ))); + } + } + ThreadItem::Reasoning { + summary, content, .. + } => { + let text = if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible) + && !content.is_empty() + { + content.join("\n\n") + } else { + summary.join("\n\n") + }; + if !text.trim().is_empty() { + cells.push(Arc::new(ReasoningSummaryCell::new( + "Reasoning".to_string(), + text, + cwd, + /*transcript_only*/ false, + ))); + } + } + other => { + if let Some(cell) = fallback_transcript_cell(other) { + cells.push(Arc::new(cell)); + } + } + } + } + if cells.is_empty() { + cells.push(Arc::new(PlainHistoryCell::new(vec![ + "No transcript content available".italic().dim().into(), + ]))); + } + cells +} + +fn fallback_transcript_cell(item: &ThreadItem) -> Option { + let lines = match item { + ThreadItem::HookPrompt { fragments, .. } => fragments + .iter() + .map(|fragment| { + vec![ + "hook prompt: ".dim(), + fragment.text.trim().to_string().into(), + ] + .into() + }) + .collect::>(), + ThreadItem::CommandExecution { + command, + status, + aggregated_output, + exit_code, + .. + } => { + let mut lines: Vec> = + vec![vec!["$ ".dim(), command.clone().into()].into()]; + lines.push( + format!( + "status: {status:?}{}", + exit_code + .map(|code| format!(" · exit {code}")) + .unwrap_or_default() + ) + .dim() + .into(), + ); + if let Some(output) = aggregated_output.as_deref() + && !output.trim().is_empty() + { + lines.extend( + output + .lines() + .map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()), + ); + } + lines + } + ThreadItem::FileChange { + changes, status, .. + } => vec![ + format!("file changes: {status:?} · {} changes", changes.len()) + .dim() + .into(), + ], + ThreadItem::McpToolCall { + server, + tool, + status, + .. + } => vec![ + format!("mcp tool: {server}/{tool} · {status:?}") + .dim() + .into(), + ], + ThreadItem::DynamicToolCall { + namespace, + tool, + status, + .. + } => { + let name = namespace + .as_ref() + .map(|namespace| format!("{namespace}/{tool}")) + .unwrap_or_else(|| tool.clone()); + vec![format!("tool: {name} · {status:?}").dim().into()] + } + ThreadItem::CollabAgentToolCall { tool, status, .. } => { + vec![format!("agent tool: {tool:?} · {status:?}").dim().into()] + } + ThreadItem::WebSearch { query, .. } => { + vec![vec!["web search: ".dim(), query.clone().into()].into()] + } + ThreadItem::ImageView { path, .. } => { + vec![format!("image: {}", path.as_path().display()).dim().into()] + } + ThreadItem::ImageGeneration { + status, saved_path, .. + } => { + let saved = saved_path + .as_ref() + .map(|path| format!(" · {}", path.as_path().display())) + .unwrap_or_default(); + vec![format!("image generation: {status}{saved}").dim().into()] + } + ThreadItem::EnteredReviewMode { review, .. } => { + vec![vec!["review started: ".dim(), review.clone().into()].into()] + } + ThreadItem::ExitedReviewMode { review, .. } => { + vec![vec!["review finished: ".dim(), review.clone().into()].into()] + } + ThreadItem::ContextCompaction { .. } => { + vec!["context compacted".dim().into()] + } + ThreadItem::UserMessage { .. } + | ThreadItem::AgentMessage { .. } + | ThreadItem::Plan { .. } + | ThreadItem::Reasoning { .. } => return None, + }; + (!lines.is_empty()).then(|| PlainHistoryCell::new(lines)) +} diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index ec0f7789d7..e4d2dbab96 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -17,6 +17,12 @@ pub(crate) struct SessionNetworkProxyRuntime { pub(crate) socks_addr: String, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct MessageHistoryMetadata { + pub(crate) log_id: u64, + pub(crate) entry_count: usize, +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadSessionState { pub(crate) thread_id: ThreadId, @@ -25,7 +31,7 @@ pub(crate) struct ThreadSessionState { pub(crate) thread_name: Option, pub(crate) model: String, pub(crate) model_provider_id: String, - pub(crate) service_tier: Option, + pub(crate) service_tier: Option, pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, /// Canonical active permissions for this session. Legacy app-server @@ -38,8 +44,7 @@ pub(crate) struct ThreadSessionState { pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, - pub(crate) history_log_id: u64, - pub(crate) history_entry_count: u64, + pub(crate) message_history: Option, pub(crate) network_proxy: Option, pub(crate) rollout_path: Option, } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e05edac6b5..d5e923f0e3 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -41,6 +41,7 @@ pub enum SlashCommand { Agent, Side, Copy, + Raw, Diff, Mention, Status, @@ -88,6 +89,7 @@ impl SlashCommand { SlashCommand::Fork => "fork the current chat", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Copy => "copy last response as markdown", + SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", @@ -153,6 +155,7 @@ impl SlashCommand { | SlashCommand::Ide | SlashCommand::Keymap | SlashCommand::Mcp + | SlashCommand::Raw | SlashCommand::Side | SlashCommand::Resume | SlashCommand::SandboxReadRoot @@ -164,6 +167,7 @@ impl SlashCommand { matches!( self, SlashCommand::Copy + | SlashCommand::Raw | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status @@ -197,6 +201,7 @@ impl SlashCommand { | SlashCommand::MemoryUpdate => false, SlashCommand::Diff | SlashCommand::Copy + | SlashCommand::Raw | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills @@ -268,6 +273,9 @@ mod tests { assert!(SlashCommand::Ide.available_during_task()); assert!(SlashCommand::Title.available_during_task()); assert!(SlashCommand::Statusline.available_during_task()); + assert!(SlashCommand::Raw.available_during_task()); + assert!(SlashCommand::Raw.available_in_side_conversation()); + assert!(SlashCommand::Raw.supports_inline_args()); } #[test] diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__hooks_needing_review_startup_warning.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hooks_needing_review_startup_warning.snap new file mode 100644 index 0000000000..f044b95e86 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hooks_needing_review_startup_warning.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- +⚠ 2 hooks need review before they can run. Open /hooks to review them. diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap new file mode 100644 index 0000000000..5f18eb209d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap @@ -0,0 +1,58 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +rich before: + +› Please format this + for copying + +• - first item + - second item + + | Col | Value | + | --- | --- | + | code | x = 1 | + + copy me +• Called + └ workspace.inspect({"path":"README.md + "}) + structured output + second line + +raw on: +Please format this +for copying +- first item +- second item + +| Col | Value | +| --- | --- | +| code | `x = 1` | + +```text +copy me +``` +Called workspace.inspect({"path":"README.md"}) +structured output +second line + +rich after: + +› Please format this + for copying + +• - first item + - second item + + | Col | Value | + | --- | --- | + | code | x = 1 | + + copy me +• Called + └ workspace.inspect({"path":"README.md + "}) + structured output + second line diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 0633d837cc..4e388179a3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -7,10 +7,10 @@ Open External Editor | ctrl-g | Global open_external_editor Open External Editor Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default +Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default -History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index ab398780e4..f58386a8d5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 86 actions, 1 customized, 2 unbound. + 87 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -15,8 +15,8 @@ expression: "render_picker(params, 120)" Global Copy ctrl-o Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. - Chat Edit Queued Message alt-up, shift-left left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 7538c21500..1c247951fd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 87 actions, 0 customized, 3 unbound. + 88 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global - Toggle Fast Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 24b8d81b77..e094edad97 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (86 selectable) +tab: All (87 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) -tab: App (8 selectable) +tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (34 selectable) @@ -18,10 +18,10 @@ Open External Editor | ctrl-g | Global open_external_editor Open External Editor Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default +Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default -History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 9132b3403d..f146c7eed1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 86 actions, 0 customized, 2 unbound. + 87 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,8 +16,8 @@ expression: "render_picker(params, 78)" Global Copy ctrl-o Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. - Chat Edit Queued Message alt-up, shift-left left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index b6cc5dfa2d..c9b8be80fd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 86 actions, 0 customized, 2 unbound. + 87 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -15,8 +15,8 @@ expression: "render_picker(params, 120)" Global Copy ctrl-o Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound + Global Toggle Raw Output alt-r Chat Decrease Reasoning Effort alt-, Chat Increase Reasoning Effort alt-. - Chat Edit Queued Message alt-up, shift-left left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap new file mode 100644 index 0000000000..62cb8d199b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 120,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap new file mode 100644 index 0000000000..94f74b55f3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 100,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap new file mode 100644 index 0000000000..9b8c39ef50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap new file mode 100644 index 0000000000..64120ef26a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(false,\nSome(PathBuf::from(\"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs\")),\n100,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap new file mode 100644 index 0000000000..9b8c39ef50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap new file mode 100644 index 0000000000..50e37a46b9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- + 15m ago First dense row +❯ 15m ago Second dense row diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap new file mode 100644 index 0000000000..23470e5a6f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/resume_picker.rs +expression: rendered +--- +⌄ Investigate picker expansion + │ Session: 019dabc1-0ef5-7431-b81c-03037f51f62c + │ Created: 1 hour ago · 2026-04-28 16:30:00 + │ Updated: 15 minutes ago · 2026-04-28 17:45:00 + │ Directory: /tmp/codex + │ Branch:  fcoury/session-picker + │ + │ Conversation: + │ Show me the recent transcript + └ Here are the last few lines. diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap new file mode 100644 index 0000000000..65fb6f7857 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: "footer_snapshot(&state, 96, 20)" +--- +───────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─ + enter resume esc clear ctrl+c quit tab focus ←/→ option + ctrl+o comfy ctrl+t preview ctrl+e exp ↑/↓ browse diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap new file mode 100644 index 0000000000..13c111dc1d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: "footer_snapshot(&state, 220, 20)" +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─ + enter resume esc start new ctrl+c quit tab focus sort/filter ←/→ change option + ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap new file mode 100644 index 0000000000..76a645aa07 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +↑ more +❯ item-2 + 10m ago ⌁ no cwd  no branch + + item-3 +↓ more diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap new file mode 100644 index 0000000000..db583b9957 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +❯ Investigate picker expansion + 15m ago ⌁ /tmp/codex +  fcoury/session-picker diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap new file mode 100644 index 0000000000..f48b6543cd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +Type to search Filter: [Cwd] All Sort: [Updated] Created diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap index 8948163563..b882050d58 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -2,7 +2,11 @@ source: tui/src/resume_picker.rs expression: snapshot --- - Created Updated Branch CWD Conversation - 16 minutes ago 42 seconds ago - - Fix resume picker timestamps -> 1 hour ago 35 minutes ago - - Investigate lazy pagination cap - 2 hours ago 2 hours ago - - Explain the codebase + Fix resume picker timestamps + 42s ago ⌁ no cwd  no branch + +❯ Investigate lazy pagination cap + 35m ago ⌁ no cwd  no branch + + Explain the codebase + 2h ago ⌁ no cwd  no branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap new file mode 100644 index 0000000000..fa6b47cbbd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- +❯ Find pending threads and emails + - ⌁ no cwd  no branch + + Plan raw scrollback mod Loading transcript… + - ⌁ no cwd branch diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 596c10aa75..aa98a20bc4 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -1,6 +1,7 @@ use crate::history_cell::CompositeHistoryCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; +use crate::history_cell::plain_lines; use crate::history_cell::with_border_with_inner_width; use crate::legacy_core::config::Config; use crate::token_usage::TokenUsage; @@ -788,6 +789,10 @@ impl HistoryCell for StatusHistoryCell { with_border_with_inner_width(truncated_lines, inner_width) } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } } fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> Option { diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 2def4ae8bb..5d903c91cc 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -11,6 +11,8 @@ //! scrollback from finalized cells. use crate::history_cell::HistoryCell; +use crate::history_cell::HistoryRenderMode; +use crate::history_cell::raw_lines_from_source; use crate::history_cell::{self}; use crate::markdown::append_markdown; use crate::render::line_utils::prefix_lines; @@ -39,10 +41,11 @@ struct StreamCore { enqueued_len: usize, emitted_len: usize, cwd: PathBuf, + render_mode: HistoryRenderMode, } impl StreamCore { - fn new(width: Option, cwd: &Path) -> Self { + fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { Self { state: StreamState::new(width, cwd), width, @@ -51,6 +54,7 @@ impl StreamCore { enqueued_len: 0, emitted_len: 0, cwd: cwd.to_path_buf(), + render_mode, } } @@ -77,13 +81,7 @@ impl StreamCore { self.raw_source.push_str(&remainder_source); } - let mut rendered = Vec::new(); - append_markdown( - &self.raw_source, - self.width, - Some(self.cwd.as_path()), - &mut rendered, - ); + let rendered = self.render_source(&self.raw_source); if self.emitted_len >= rendered.len() { Vec::new() } else { @@ -150,6 +148,27 @@ impl StreamCore { self.rebuild_queue_from_render(); } + fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + if self.render_mode == render_mode { + return; + } + + let had_pending_queue = self.state.queued_len() > 0; + self.render_mode = render_mode; + if self.raw_source.is_empty() { + return; + } + + self.recompute_render(); + self.emitted_len = self.emitted_len.min(self.rendered_lines.len()); + self.state.clear_queue(); + if self.emitted_len > 0 && !had_pending_queue { + self.enqueued_len = self.rendered_lines.len(); + return; + } + self.rebuild_queue_from_render(); + } + fn clear_queue(&mut self) { self.state.clear_queue(); self.enqueued_len = self.emitted_len; @@ -164,13 +183,18 @@ impl StreamCore { } fn recompute_render(&mut self) { - self.rendered_lines.clear(); - append_markdown( - &self.raw_source, - self.width, - Some(self.cwd.as_path()), - &mut self.rendered_lines, - ); + self.rendered_lines = self.render_source(&self.raw_source); + } + + fn render_source(&self, source: &str) -> Vec> { + match self.render_mode { + HistoryRenderMode::Rich => { + let mut rendered = Vec::new(); + append_markdown(source, self.width, Some(self.cwd.as_path()), &mut rendered); + rendered + } + HistoryRenderMode::Raw => raw_lines_from_source(source), + } } /// Append newly rendered lines to the live queue without replaying already queued rows. @@ -227,9 +251,9 @@ impl StreamController { /// `width` is the content width available to markdown rendering, not necessarily the full /// terminal width. Passing a stale width after resize will keep queued live output wrapped for /// the old viewport until app-level reflow repairs the finalized transcript. - pub(crate) fn new(width: Option, cwd: &Path) -> Self { + pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { Self { - core: StreamCore::new(width, cwd), + core: StreamCore::new(width, cwd, render_mode), header_emitted: false, } } @@ -289,6 +313,10 @@ impl StreamController { self.core.set_width(width); } + pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + self.core.set_render_mode(render_mode); + } + fn emit(&mut self, lines: Vec>) -> Option> { if lines.is_empty() { return None; @@ -317,9 +345,9 @@ impl PlanStreamController { /// /// The width has the same meaning as in `StreamController`: it is the markdown body width, and /// callers must update it when the terminal width changes. - pub(crate) fn new(width: Option, cwd: &Path) -> Self { + pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { Self { - core: StreamCore::new(width, cwd), + core: StreamCore::new(width, cwd, render_mode), header_emitted: false, top_padding_emitted: false, } @@ -385,6 +413,10 @@ impl PlanStreamController { self.core.set_width(width); } + pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) { + self.core.set_render_mode(render_mode); + } + fn emit( &mut self, lines: Vec>, @@ -436,11 +468,11 @@ mod tests { } fn stream_controller(width: Option) -> StreamController { - StreamController::new(width, &test_cwd()) + StreamController::new(width, &test_cwd(), HistoryRenderMode::Rich) } fn plan_stream_controller(width: Option) -> PlanStreamController { - PlanStreamController::new(width, &test_cwd()) + PlanStreamController::new(width, &test_cwd(), HistoryRenderMode::Rich) } fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index d7f14c8a3a..24f6e6f946 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -40,6 +40,7 @@ use tokio_stream::Stream; pub use self::frame_requester::FrameRequester; use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; +use crate::insert_history::HistoryLineWrapPolicy; use crate::notifications::DesktopNotificationBackend; use crate::notifications::detect_backend; use crate::tui::event_stream::EventBroker; @@ -369,7 +370,7 @@ pub struct Tui { draw_tx: broadcast::Sender<()>, event_broker: Arc, pub(crate) terminal: Terminal, - pending_history_lines: Vec>, + pending_history_lines: Vec, alt_saved_viewport: Option, #[cfg(unix)] suspend_context: SuspendContext, @@ -385,6 +386,11 @@ pub struct Tui { alt_screen_enabled: bool, } +struct PendingHistoryLines { + lines: Vec>, + wrap_policy: HistoryLineWrapPolicy, +} + impl Tui { pub fn new(terminal: Terminal) -> Self { let (draw_tx, _) = broadcast::channel(1); @@ -582,7 +588,25 @@ impl Tui { } pub fn insert_history_lines(&mut self, lines: Vec>) { - self.pending_history_lines.extend(lines); + self.insert_history_lines_with_wrap_policy(lines, HistoryLineWrapPolicy::PreWrap); + } + + pub fn insert_history_lines_with_wrap_policy( + &mut self, + lines: Vec>, + wrap_policy: HistoryLineWrapPolicy, + ) { + if lines.is_empty() { + return; + } + if let Some(last) = self.pending_history_lines.last_mut() + && last.wrap_policy == wrap_policy + { + last.lines.extend(lines); + } else { + self.pending_history_lines + .push(PendingHistoryLines { lines, wrap_policy }); + } self.frame_requester().schedule_frame(); } @@ -698,18 +722,21 @@ impl Tui { /// invalidate the diff buffer for a full repaint. fn flush_pending_history_lines( terminal: &mut Terminal, - pending_history_lines: &mut Vec>, + pending_history_lines: &mut Vec, is_zellij: bool, ) -> Result { if pending_history_lines.is_empty() { return Ok(false); } - crate::insert_history::insert_history_lines_with_mode( - terminal, - pending_history_lines.clone(), - crate::insert_history::InsertHistoryMode::new(is_zellij), - )?; + for batch in pending_history_lines.iter() { + crate::insert_history::insert_history_lines_with_mode_and_wrap_policy( + terminal, + batch.lines.clone(), + crate::insert_history::InsertHistoryMode::new(is_zellij), + batch.wrap_policy, + )?; + } pending_history_lines.clear(); Ok(is_zellij) } diff --git a/codex-rs/tui/src/workspace_command.rs b/codex-rs/tui/src/workspace_command.rs index f0267699a2..c6b2e770e6 100644 --- a/codex-rs/tui/src/workspace_command.rs +++ b/codex-rs/tui/src/workspace_command.rs @@ -1,14 +1,14 @@ //! App-server-backed workspace command execution for TUI-owned background lookups. //! -//! This module is the TUI boundary for short, non-interactive commands that need to run wherever +//! This module is the TUI boundary for non-interactive commands that need to run wherever //! the active workspace lives. Callers describe a command in terms of argv, cwd, environment //! overrides, timeout, and output cap; the runner translates that request to app-server //! `command/exec`. Keeping this as a TUI-local abstraction lets status surfaces avoid knowing //! whether the current app-server is embedded or remote. //! -//! Commands sent through this path are best-effort metadata probes. They should not prompt for -//! stdin, should tolerate failure by omitting optional UI, and should keep output bounded so a -//! status-line refresh cannot grow into an unbounded background process. +//! Commands sent through this path should not prompt for stdin. Most callers should keep output +//! bounded so metadata refreshes cannot grow into unbounded background processes; callers that own a +//! full user-visible payload, such as `/diff`, can explicitly opt out of output capping. use std::collections::HashMap; use std::future::Future; @@ -45,17 +45,20 @@ pub(crate) struct WorkspaceCommand { pub(crate) timeout: Duration, /// Maximum captured stdout/stderr bytes returned by app-server. pub(crate) output_bytes_cap: usize, + /// Whether app-server should return uncapped stdout/stderr. + pub(crate) disable_output_cap: bool, } impl WorkspaceCommand { - /// Creates a workspace command with conservative defaults for status-style metadata probes. + /// Creates a workspace command with conservative defaults for metadata probes. pub(crate) fn new(argv: impl IntoIterator>) -> Self { Self { argv: argv.into_iter().map(Into::into).collect(), cwd: None, env: HashMap::new(), - timeout: Duration::from_secs(5), + timeout: Duration::from_secs(/*secs*/ 5), output_bytes_cap: 64 * 1024, + disable_output_cap: false, } } @@ -70,6 +73,18 @@ impl WorkspaceCommand { self.env.insert(key.into(), Some(value.into())); self } + + /// Sets the maximum wall-clock duration before app-server cancels the command. + pub(crate) fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Requests uncapped stdout/stderr capture from app-server. + pub(crate) fn disable_output_cap(mut self) -> Self { + self.disable_output_cap = true; + self + } } /// Captured result from a completed workspace command. @@ -176,8 +191,9 @@ impl WorkspaceCommandExecutor for AppServerWorkspaceCommandRunner { tty: false, stream_stdin: false, stream_stdout_stderr: false, - output_bytes_cap: Some(command.output_bytes_cap), - disable_output_cap: false, + output_bytes_cap: (!command.disable_output_cap) + .then_some(command.output_bytes_cap), + disable_output_cap: command.disable_output_cap, disable_timeout: false, timeout_ms: Some(timeout_ms), cwd: command.cwd, diff --git a/codex-rs/tui/tests/fixtures/oss-story.jsonl b/codex-rs/tui/tests/fixtures/oss-story.jsonl index 72d0fc40f4..a62182f487 100644 --- a/codex-rs/tui/tests/fixtures/oss-story.jsonl +++ b/codex-rs/tui/tests/fixtures/oss-story.jsonl @@ -2,7 +2,7 @@ {"ts":"2025-08-10T03:12:26.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} {"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} -{"ts":"2025-08-10T03:12:26.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"8f7c4ac2-6141-42da-b4d5-7032a8e8df3b","model":"gpt-oss:20b","history_log_id":2532619,"history_entry_count":355}}} +{"ts":"2025-08-10T03:12:26.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"8f7c4ac2-6141-42da-b4d5-7032a8e8df3b","model":"gpt-oss:20b"}}} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"insert_history","lines":9} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index 8a8ada4620..dec24d99d8 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -1,9 +1,17 @@ //! Plugin path resolution, plaintext mention sigils, and MCP connector helpers shared across Codex //! crates. +use codex_utils_absolute_path::AbsolutePathBuf; + pub mod mcp_connector; pub mod mention_syntax; pub mod plugin_namespace; pub use plugin_namespace::find_plugin_manifest_path; pub use plugin_namespace::plugin_namespace_for_skill_path; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PluginSkillRoot { + pub path: AbsolutePathBuf, + pub plugin_id: String, +} diff --git a/codex-rs/v8-poc/BUILD.bazel b/codex-rs/v8-poc/BUILD.bazel index 0cadbd518d..05a46451a9 100644 --- a/codex-rs/v8-poc/BUILD.bazel +++ b/codex-rs/v8-poc/BUILD.bazel @@ -3,6 +3,10 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "v8-poc", crate_name = "codex_v8_poc", + crate_features = select({ + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc": [], + "//conditions:default": ["sandbox"], + }), deps_extra = ["@crates//:v8"], ) diff --git a/codex-rs/v8-poc/Cargo.toml b/codex-rs/v8-poc/Cargo.toml index 4bf008c095..cbcb7ca404 100644 --- a/codex-rs/v8-poc/Cargo.toml +++ b/codex-rs/v8-poc/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true name = "codex_v8_poc" path = "src/lib.rs" +[features] +sandbox = ["v8/v8_enable_sandbox"] + [lints] workspace = true diff --git a/codex-rs/v8-poc/src/lib.rs b/codex-rs/v8-poc/src/lib.rs index 0e9faaab10..f43a9a1253 100644 --- a/codex-rs/v8-poc/src/lib.rs +++ b/codex-rs/v8-poc/src/lib.rs @@ -12,6 +12,17 @@ pub fn embedded_v8_version() -> &'static str { v8::V8::get_version() } +/// Returns whether the linked V8 library was built with the in-process sandbox. +#[must_use] +pub fn linked_v8_has_sandbox() -> bool { + unsafe extern "C" { + fn v8__V8__IsSandboxEnabled() -> bool; + } + + // `rusty_v8` exposes this symbol for its own sandbox verification tests. + unsafe { v8__V8__IsSandboxEnabled() } +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; @@ -53,6 +64,11 @@ mod tests { assert!(!super::embedded_v8_version().is_empty()); } + #[test] + fn sandbox_feature_matches_linked_v8() { + assert_eq!(super::linked_v8_has_sandbox(), cfg!(feature = "sandbox")); + } + #[test] fn evaluates_integer_addition() { assert_eq!(evaluate_expression("1 + 2"), "3"); diff --git a/codex-rs/vendor/bubblewrap/NEWS.md b/codex-rs/vendor/bubblewrap/NEWS.md index da232c4bd7..eb82ed98d9 100644 --- a/codex-rs/vendor/bubblewrap/NEWS.md +++ b/codex-rs/vendor/bubblewrap/NEWS.md @@ -1,3 +1,50 @@ +bubblewrap 0.11.2 +================= + +Released: 2026-04-23 + +Bug fixes: + + * In setuid mode, don't run the low-privileged parts parts of the setup + as dumpable, as that allows it to be ptraced which can lead to problems. + This is CVE-2026-41163, and was reported by François Diakhate. + +Enhancements: + + * New build option `-Dsupport_setuid`, which if set to false (which + is the default) disables the support for setuid. Binaries built + with this will refuse to run if made setuid. We recommend building + normal bubblewrap binaries like this, which allows you to safely + ignore any security issues that only affect setuid mode. + +bubblewrap 0.11.1 +================= + +Released: 2026-03-21 + +Bug fixes: + + * Reset disposition of `SIGCHLD`, restoring normal subprocess management + if bwrap was run from a process that was ignoring that signal, + such as Erlang or volumeicon (#705, Joel Pelaez Jorge) + + * Don't ignore `--userns 0`, `--userns2 0` or `--pidns 0` if used + (#731, Daniel Cazares). + Note that using a fd number ≥ 3 for these purposes is still + preferred, to avoid confusion with the stdin, stdout, stderr + that will be inherited by the command inside the container. + + * Fix grammar in an error message (#694, J. Neuschäfer) + + * Fix a broken link in the documentation (#729, Aaron Brooks) + +Internal changes: + + * Enable user namespaces in Github Actions configuration, fixing a CI + regression with newer Ubuntu (#728, Joel Pelaez Jorge) + + * Clarify comments (#737, Simon McVittie) + bubblewrap 0.11.0 ================= diff --git a/codex-rs/vendor/bubblewrap/README.md b/codex-rs/vendor/bubblewrap/README.md index c16cd7d89a..1f838ce0a1 100644 --- a/codex-rs/vendor/bubblewrap/README.md +++ b/codex-rs/vendor/bubblewrap/README.md @@ -12,23 +12,24 @@ on the host. User namespaces --------------- -There is an effort in the Linux kernel called +There is an feature in the Linux kernel called [user namespaces](https://www.google.com/search?q=user+namespaces+site%3Ahttps%3A%2F%2Flwn.net) -which attempts to allow unprivileged users to use container features. -While significant progress has been made, there are -[still concerns](https://lwn.net/Articles/673597/) about it, and -it is not available to unprivileged users in several production distributions -such as CentOS/Red Hat Enterprise Linux 7, Debian Jessie, etc. +which allows unprivileged users to use container features. Bubblewrap uses these to +build the sandbox, allowing any user to use the tool. -See for example -[CVE-2016-3135](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-3135) -which is a local root vulnerability introduced by userns. -[This March 2016 post](https://lkml.org/lkml/2016/3/9/555) has some -more discussion. +Historically, not all Linux distributions supported (at least by +default) unprivileged user namespaces, so bubblewrap supports a second +mode of operation when the binary is setuid root. In that setup +bubblewrap could be viewed as setuid implementation of a *subset* of +user namespaces. However, not all features of bubblewrap work in +this mode. -Bubblewrap could be viewed as setuid implementation of a *subset* of -user namespaces. Emphasis on subset - specifically relevant to the -above CVE, bubblewrap does not allow control over iptables. +However, setuid mode is deprecated, as most recent Linux distributions +support unprivileged user namespaces, and setuid binaries carry +significant risks. By default, bubblewrap binaries refuse to work if +setuid, and you must build explicitly with ` -Dsupport_setuid=true` to +enable it to work. Later versions of bubblewrap aims to completely +remove this support. The original bubblewrap code existed before user namespaces - it inherits code from [xdg-app helper](https://cgit.freedesktop.org/xdg-app/xdg-app/tree/common/xdg-app-helper.c?id=4c3bf179e2e4a2a298cd1db1d045adaf3f564532) @@ -151,7 +152,7 @@ sandbox. You can also change what the value of uid/gid should be in the sandbox. IPC namespaces ([CLONE_NEWIPC](https://linux.die.net/man/2/clone)): The sandbox will get its own copy of all the different forms of IPCs, like SysV shared memory and semaphores. -PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/). +PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/docker-and-the-pid-1-zombie-reaping-problem/). Network namespaces ([CLONE_NEWNET](https://linux.die.net/man/2/clone)): The sandbox will not see the network. Instead it will have its own network namespace with only a loopback device. diff --git a/codex-rs/vendor/bubblewrap/SECURITY.md b/codex-rs/vendor/bubblewrap/SECURITY.md index 0ddfc6c873..7e0fb32746 100644 --- a/codex-rs/vendor/bubblewrap/SECURITY.md +++ b/codex-rs/vendor/bubblewrap/SECURITY.md @@ -15,6 +15,13 @@ between the user and the OS, because anything bubblewrap could do, a malicious user could equally well do by writing their own tool equivalent to bubblewrap. +Since 0.11.2, unless compiled with the `-Dsupport_setuid=true` option, +setuid root support is disabled. In this mode bubblewrap will refuse +to operate if the binary has been made setuid. For binaries built like +this it is safe to ignore any bubblewrap CVEs that are described as +affecting setuid mode only. This is the recommended way to package +bubblewrap. + ### Sandbox security bubblewrap is a toolkit for constructing sandbox environments. diff --git a/codex-rs/vendor/bubblewrap/bubblewrap.c b/codex-rs/vendor/bubblewrap/bubblewrap.c index 69d319b7a3..9039ddfa80 100644 --- a/codex-rs/vendor/bubblewrap/bubblewrap.c +++ b/codex-rs/vendor/bubblewrap/bubblewrap.c @@ -55,7 +55,11 @@ static uid_t real_uid; static gid_t real_gid; static uid_t overflow_uid; static gid_t overflow_gid; +#ifdef ENABLE_SUPPORT_SETUID static bool is_privileged; /* See acquire_privs() */ +#else +#define is_privileged 0 +#endif static const char *argv0; static const char *host_tty_dev; static int proc_fd = -1; @@ -840,13 +844,16 @@ set_ambient_capabilities (void) static void acquire_privs (void) { - uid_t euid, new_fsuid; + uid_t euid; euid = geteuid (); /* Are we setuid ? */ if (real_uid != euid) { +#ifdef ENABLE_SUPPORT_SETUID + uid_t new_fsuid; + if (euid != 0) die ("Unexpected setuid user %d, should be 0", euid); @@ -868,13 +875,16 @@ acquire_privs (void) /* setfsuid can't properly report errors, check that it worked (as per manpage) */ new_fsuid = setfsuid (-1); if (new_fsuid != real_uid) - die ("Unable to set fsuid (was %d)", (int)new_fsuid); + die_with_error ("Unable to set fsuid (was %d)", (int)new_fsuid); /* We never need capabilities after execve(), so lets drop everything from the bounding set */ drop_cap_bounding_set (true); /* Keep only the required capabilities for setup */ set_required_caps (); +#else + die ("setuid use of bubblewrap is not supported in this build"); +#endif } else if (real_uid != 0 && has_caps ()) { @@ -937,7 +947,8 @@ switch_to_user_with_privs (void) /* Call setuid() and use capset() to adjust capabilities */ static void drop_privs (bool keep_requested_caps, - bool already_changed_uid) + bool already_changed_uid, + bool set_dumpable) { assert (!keep_requested_caps || !is_privileged); /* Drop root uid */ @@ -947,9 +958,12 @@ drop_privs (bool keep_requested_caps, drop_all_caps (keep_requested_caps); - /* We don't have any privs now, so mark us dumpable which makes /proc/self be owned by the user instead of root */ - if (prctl (PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) - die_with_error ("can't set dumpable"); + if (set_dumpable) + { + /* We don't have any privs now, so mark us dumpable which makes /proc/self be owned by the user instead of root */ + if (prctl (PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) + die_with_error ("can't set dumpable"); + } } static void @@ -1154,7 +1168,9 @@ privileged_op (int privileged_op_socket, break; case PRIV_SEP_OP_OVERLAY_MOUNT: - if (mount ("overlay", arg2, "overlay", MS_MGC_VAL, arg1) != 0) + if (is_privileged) + die ("Overlay mounts are not supported in setuid mode"); + if (mount ("overlay", arg2, "overlay", MS_MGC_VAL | MS_NOSUID | MS_NODEV, arg1) != 0) { /* The standard message for ELOOP, "Too many levels of symbolic * links", is not helpful here. */ @@ -1172,6 +1188,8 @@ privileged_op (int privileged_op_socket, something manages to send hacked priv-sep operation requests. */ if (!opt_unshare_uts) die ("Refusing to set hostname in original namespace"); + if (arg1 == NULL) + die ("Hostname argument is NULL"); if (sethostname (arg1, strlen(arg1)) != 0) die_with_error ("Can't set hostname to %s", arg1); break; @@ -3112,7 +3130,7 @@ main (int argc, } /* Switch to the custom user ns before the clone, gets us privs in that ns (assuming its a child of the current and thus allowed) */ - if (opt_userns_fd > 0 && setns (opt_userns_fd, CLONE_NEWUSER) != 0) + if (opt_userns_fd != -1 && setns (opt_userns_fd, CLONE_NEWUSER) != 0) { if (errno == EINVAL) die ("Joining the specified user namespace failed, it might not be a descendant of the current user namespace."); @@ -3178,11 +3196,11 @@ main (int argc, /* Initial launched process, wait for pid 1 or exec:ed command to exit */ - if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); /* We don't need any privileges in the launcher, drop them immediately. */ - drop_privs (false, false); + drop_privs (false, false, true); /* Optionally bind our lifecycle to that of the parent */ handle_die_with_parent (); @@ -3219,7 +3237,7 @@ main (int argc, return monitor_child (event_fd, pid, setup_finished_pipe[0]); } - if (opt_pidns_fd > 0) + if (opt_pidns_fd != -1) { if (setns (opt_pidns_fd, CLONE_NEWPID) != 0) die_with_error ("Setting pidns failed"); @@ -3369,8 +3387,10 @@ main (int argc, if (child == 0) { - /* Unprivileged setup process */ - drop_privs (false, true); + /* Unprivileged setup process. + * Note: Don't set dumpable, because we can still perform privileged + * operations via privileged_op(). */ + drop_privs (false, true, false); close (privsep_sockets[0]); setup_newroot (opt_unshare_pid, privsep_sockets[1]); exit (0); @@ -3446,7 +3466,7 @@ main (int argc, die_with_error ("chdir /"); } - if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); if (opt_unshare_user && opt_userns_block_fd == -1 && @@ -3499,7 +3519,7 @@ main (int argc, } /* All privileged ops are done now, so drop caps we don't need */ - drop_privs (!is_privileged, true); + drop_privs (!is_privileged, true, true); if (opt_block_fd != -1) { diff --git a/codex-rs/vendor/bubblewrap/meson.build b/codex-rs/vendor/bubblewrap/meson.build index 78678d0973..520d0a5f47 100644 --- a/codex-rs/vendor/bubblewrap/meson.build +++ b/codex-rs/vendor/bubblewrap/meson.build @@ -1,7 +1,7 @@ project( 'bubblewrap', 'c', - version : '0.11.0', + version : '0.11.2', meson_version : '>=0.49.0', default_options : [ 'warning_level=2', @@ -91,6 +91,11 @@ if get_option('require_userns') cdata.set('ENABLE_REQUIRE_USERNS', 1) endif +if get_option('support_setuid') + cdata.set('ENABLE_SUPPORT_SETUID', 1) + warning('running bubblewrap setuid is deprecated and risky. Most recent operating systems support unprivileged user namespaces and we recommend using that. Support for this will be removed in the next version.') +endif + configure_file( output : 'config.h', configuration : cdata, diff --git a/codex-rs/vendor/bubblewrap/meson_options.txt b/codex-rs/vendor/bubblewrap/meson_options.txt index 5e25ee86f8..05b1c7873e 100644 --- a/codex-rs/vendor/bubblewrap/meson_options.txt +++ b/codex-rs/vendor/bubblewrap/meson_options.txt @@ -41,6 +41,12 @@ option( type : 'string', description : 'Path to Python 3, or empty to use python3', ) +option( + 'support_setuid', + type : 'boolean', + description : 'Support setuid mode (deprecated)', + value : false, +) option( 'require_userns', type : 'boolean', diff --git a/codex-rs/vendor/bubblewrap/network.c b/codex-rs/vendor/bubblewrap/network.c index 106e6d6e36..373d606a0a 100644 --- a/codex-rs/vendor/bubblewrap/network.c +++ b/codex-rs/vendor/bubblewrap/network.c @@ -50,7 +50,7 @@ static int rtnl_send_request (int rtnl_fd, struct nlmsghdr *header) { - struct sockaddr_nl dst_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + struct sockaddr_nl dst_addr = { AF_NETLINK, 0 }; ssize_t sent; sent = TEMP_FAILURE_RETRY (sendto (rtnl_fd, (void *) header, header->nlmsg_len, 0, @@ -139,7 +139,7 @@ loopback_setup (void) int r, if_loopback; cleanup_fd int rtnl_fd = -1; char buffer[1024]; - struct sockaddr_nl src_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + struct sockaddr_nl src_addr = { AF_NETLINK, 0 }; struct nlmsghdr *header; struct ifaddrmsg *addmsg; struct ifinfomsg *infomsg; diff --git a/codex-rs/vendor/bubblewrap/release-checklist.md b/codex-rs/vendor/bubblewrap/release-checklist.md index 5b2119c8af..0c1479e447 100644 --- a/codex-rs/vendor/bubblewrap/release-checklist.md +++ b/codex-rs/vendor/bubblewrap/release-checklist.md @@ -1,13 +1,13 @@ bubblewrap release checklist ============================ -* Collect release notes in `NEWS` -* Update version number in `meson.build` and release date in `NEWS` +* Collect release notes in `NEWS.md` +* Update version number in `meson.build` and release date in `NEWS.md` * Commit the changes * `meson dist -C ${builddir}` * Do any final smoke-testing, e.g. update a package, install and test it * `git evtag sign v$VERSION` - * Include the release notes from `NEWS` in the tag message + * Include the release notes from `NEWS.md` in the tag message * `git push --atomic origin main v$VERSION` * https://github.com/containers/bubblewrap/releases/new * Fill in the new version's tag in the "Tag version" box diff --git a/codex-rs/vendor/bubblewrap/utils.c b/codex-rs/vendor/bubblewrap/utils.c index 51875aea9a..7b7349ab82 100644 --- a/codex-rs/vendor/bubblewrap/utils.c +++ b/codex-rs/vendor/bubblewrap/utils.c @@ -510,14 +510,18 @@ ensure_file (const char *path, the create file will fail in the read-only case with EROFS instead of EEXIST. - We're trying to set up a mount point for a non-directory, so any - non-directory, non-symlink is acceptable - it doesn't necessarily - have to be a regular file. */ + We're trying to set up a mount point for a non-directory, for which + the kernel will accept any non-directory. If it's a symlink, follow + it and look at the target: again, any non-directory is good enough. + We'll only get S_ISLNK if the path is a dangling symlink (target + doesn't exist). */ if (stat (path, &buf) == 0 && !S_ISDIR (buf.st_mode) && !S_ISLNK (buf.st_mode)) return 0; + /* If the file didn't exist, create it. If it was a dangling symlink + * (S_ISLNK above) then this will create the target of the symlink. */ if (create_file (path, mode, NULL) != 0 && errno != EEXIST) return -1; @@ -681,7 +685,8 @@ ensure_dir (const char *path, /* We check this ahead of time, otherwise the mkdir call can fail in the read-only case with EROFS instead of EEXIST on some - filesystems (such as NFS) */ + filesystems (such as NFS). + We follow symlinks: it's OK if path is a symlink to a directory. */ if (stat (path, &buf) == 0) { if (!S_ISDIR (buf.st_mode)) diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 2c1c4f79ce..be9f1cfb98 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -37,72 +37,11 @@ mod windows_impl { use crate::policy::SandboxPolicy; use crate::policy::parse_policy; use crate::runner_client::spawn_runner_transport; + use crate::sandbox_utils::ensure_codex_home_exists; + use crate::sandbox_utils::inject_git_safe_directory; use crate::token::convert_string_sid_to_sid; use anyhow::Result; - use std::collections::HashMap; use std::path::Path; - use std::path::PathBuf; - - /// Ensures the parent directory of a path exists before writing to it. - /// Walks upward from `start` to locate the git worktree root, following gitfile redirects. - fn find_git_root(start: &Path) -> Option { - let mut cur = dunce::canonicalize(start).ok()?; - loop { - let marker = cur.join(".git"); - if marker.is_dir() { - return Some(cur); - } - if marker.is_file() { - if let Ok(txt) = std::fs::read_to_string(&marker) - && let Some(rest) = txt.trim().strip_prefix("gitdir:") - { - let gitdir = rest.trim(); - let resolved = if Path::new(gitdir).is_absolute() { - PathBuf::from(gitdir) - } else { - cur.join(gitdir) - }; - return resolved.parent().map(Path::to_path_buf).or(Some(cur)); - } - return Some(cur); - } - let parent = cur.parent()?; - if parent == cur { - return None; - } - cur = parent.to_path_buf(); - } - } - - /// Creates the sandbox user's Codex home directory if it does not already exist. - fn ensure_codex_home_exists(p: &Path) -> Result<()> { - std::fs::create_dir_all(p)?; - Ok(()) - } - - /// Adds a git safe.directory entry to the environment when running inside a repository. - /// git will not otherwise allow the Sandbox user to run git commands on the repo directory - /// which is owned by the primary user. - fn inject_git_safe_directory( - env_map: &mut HashMap, - cwd: &Path, - _logs_base_dir: Option<&Path>, - ) { - if let Some(git_root) = find_git_root(cwd) { - let mut cfg_count: usize = env_map - .get("GIT_CONFIG_COUNT") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - let git_path = git_root.to_string_lossy().replace("\\\\", "/"); - env_map.insert( - format!("GIT_CONFIG_KEY_{cfg_count}"), - "safe.directory".to_string(), - ); - env_map.insert(format!("GIT_CONFIG_VALUE_{cfg_count}"), git_path); - cfg_count += 1; - env_map.insert("GIT_CONFIG_COUNT".to_string(), cfg_count.to_string()); - } - } pub use crate::windows_impl::CaptureResult; @@ -130,7 +69,7 @@ mod windows_impl { normalize_null_device_env(&mut env_map); ensure_non_interactive_pager(&mut env_map); inherit_path_env(&mut env_map); - inject_git_safe_directory(&mut env_map, cwd, None); + inject_git_safe_directory(&mut env_map, cwd); // Use a temp-based log dir that the sandbox user can write. let sandbox_base = codex_home.join(".sandbox"); ensure_codex_home_exists(&sandbox_base)?; diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs b/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs index 5d64e5f844..fa08309550 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs @@ -8,28 +8,12 @@ use anyhow::Result; use std::collections::HashMap; use std::path::Path; -use std::path::PathBuf; -/// Walk upward from `start` to locate the git worktree root (supports gitfile redirects). -fn find_git_root(start: &Path) -> Option { +/// Walk upward from `start` to locate the git worktree root for `safe.directory`. +fn find_git_worktree_root_for_safe_directory(start: &Path) -> Option { let mut cur = dunce::canonicalize(start).ok()?; loop { - let marker = cur.join(".git"); - if marker.is_dir() { - return Some(cur); - } - if marker.is_file() { - if let Ok(txt) = std::fs::read_to_string(&marker) - && let Some(rest) = txt.trim().strip_prefix("gitdir:") - { - let gitdir = rest.trim(); - let resolved = if Path::new(gitdir).is_absolute() { - PathBuf::from(gitdir) - } else { - cur.join(gitdir) - }; - return resolved.parent().map(Path::to_path_buf).or(Some(cur)); - } + if cur.join(".git").exists() { return Some(cur); } let parent = cur.parent()?; @@ -50,7 +34,7 @@ pub fn ensure_codex_home_exists(p: &Path) -> Result<()> { /// git will not otherwise allow the Sandbox user to run git commands on the repo directory /// which is owned by the primary user. pub fn inject_git_safe_directory(env_map: &mut HashMap, cwd: &Path) { - if let Some(git_root) = find_git_root(cwd) { + if let Some(git_root) = find_git_worktree_root_for_safe_directory(cwd) { let mut cfg_count: usize = env_map .get("GIT_CONFIG_COUNT") .and_then(|v| v.parse::().ok()) @@ -65,3 +49,68 @@ pub fn inject_git_safe_directory(env_map: &mut HashMap, cwd: &Pa env_map.insert("GIT_CONFIG_COUNT".to_string(), cfg_count.to_string()); } } + +#[cfg(test)] +mod tests { + use super::inject_git_safe_directory; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + use std::fs; + use std::path::Path; + use tempfile::TempDir; + + fn safe_directory_value(path: &Path) -> String { + dunce::canonicalize(path) + .expect("canonicalize path") + .to_string_lossy() + .replace("\\\\", "/") + } + + #[test] + fn injects_safe_directory_for_git_directory() { + let temp = TempDir::new().expect("tempdir"); + let repo = temp.path().join("repo"); + let nested = repo.join("nested"); + fs::create_dir_all(repo.join(".git")).expect("create .git"); + fs::create_dir_all(&nested).expect("create nested dir"); + + let mut env_map = HashMap::new(); + inject_git_safe_directory(&mut env_map, &nested); + + let expected = HashMap::from([ + ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), + ("GIT_CONFIG_KEY_0".to_string(), "safe.directory".to_string()), + ( + "GIT_CONFIG_VALUE_0".to_string(), + safe_directory_value(&repo), + ), + ]); + assert_eq!(env_map, expected); + } + + #[test] + fn injects_worktree_root_for_gitfile() { + let temp = TempDir::new().expect("tempdir"); + let repo = temp.path().join("repo"); + let nested = repo.join("nested"); + fs::create_dir_all(&nested).expect("create nested dir"); + fs::write( + repo.join(".git"), + "gitdir: C:/Users/example/repo/.git/worktrees/codex3\n", + ) + .expect("write .git file"); + + let mut env_map = HashMap::new(); + inject_git_safe_directory(&mut env_map, &nested); + + let expected = HashMap::from([ + ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), + ("GIT_CONFIG_KEY_0".to_string(), "safe.directory".to_string()), + ( + "GIT_CONFIG_VALUE_0".to_string(), + safe_directory_value(&repo), + ), + ]); + assert_eq!(env_map, expected); + } +} diff --git a/defs.bzl b/defs.bzl index fc8b0e0da6..4af81ecef1 100644 --- a/defs.bzl +++ b/defs.bzl @@ -331,6 +331,7 @@ def codex_rust_crate( rust_test( name = unit_test_binary, crate = name, + crate_features = crate_features, deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra, # Unit tests also compile to standalone Windows executables, so # keep their stack reserve aligned with binaries and integration diff --git a/docs/config.md b/docs/config.md index 950a631fc5..e0a0c5fc2f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -5,3 +5,24 @@ For basic configuration instructions, see [this documentation](https://developer For advanced configuration instructions, see [this documentation](https://developers.openai.com/codex/config-advanced). For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference). + +## Commit attribution + +Codex can add a [git trailer](https://git-scm.com/docs/git-interpret-trailers) to +generated commit messages so commits make Codex's involvement explicit. This +behavior is gated by the `codex_git_commit` feature flag; the top-level +`commit_attribution` setting is only used when that feature is enabled. + +Add the following to `~/.codex/config.toml`: + +```toml +commit_attribution = "Codex " + +[features] +codex_git_commit = true +``` + +When enabled, Codex appends a `Co-authored-by:` trailer using the configured +attribution value. If `commit_attribution` is omitted, Codex uses +`Codex `. Set `commit_attribution = ""` to disable the +trailer while leaving the feature flag enabled. diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index df845939d0..70ab440d85 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -65,10 +65,19 @@ index 9648e4a..88efd41 100644 ":should_add_rdynamic": ["-rdynamic"], "//conditions:default": [], diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel -index 85f31b7..7314584 100644 +index 85f31b7..bbc351b 100644 --- a/orig/v8-14.6.202.11/BUILD.bazel +++ b/mod/v8-14.6.202.11/BUILD.bazel -@@ -303,7 +303,7 @@ v8_int( +@@ -148,6 +148,8 @@ v8_flag(name = "v8_enable_trace_maps") + + v8_flag(name = "v8_enable_v8_checks") + ++v8_flag(name = "v8_enable_sandbox") ++ + v8_flag(name = "v8_enable_verify_csa") + + v8_flag(name = "v8_enable_verify_heap") +@@ -303,7 +305,7 @@ v8_int( # If no explicit value for v8_enable_pointer_compression, we set it to 'none'. v8_string( name = "v8_enable_pointer_compression", @@ -77,7 +86,15 @@ index 85f31b7..7314584 100644 ) # Default setting for v8_enable_pointer_compression. -@@ -4077,28 +4077,14 @@ filegroup( +@@ -503,6 +505,7 @@ v8_config( + "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", +@@ -4077,28 +4080,14 @@ filegroup( }), ) @@ -112,7 +129,7 @@ index 85f31b7..7314584 100644 ) filegroup( -@@ -4405,6 +4391,20 @@ genrule( +@@ -4405,6 +4394,20 @@ genrule( srcs = [ "include/js_protocol.pdl", "src/inspector/inspector_protocol_config.json", @@ -133,7 +150,7 @@ index 85f31b7..7314584 100644 ], outs = [ "include/inspector/Debugger.h", -@@ -4426,15 +4426,19 @@ genrule( +@@ -4426,15 +4429,19 @@ genrule( "src/inspector/protocol/Schema.cpp", "src/inspector/protocol/Schema.h", ], @@ -157,7 +174,7 @@ index 85f31b7..7314584 100644 ], ) -@@ -4448,6 +4451,15 @@ filegroup( +@@ -4448,6 +4455,15 @@ filegroup( ], ) @@ -173,7 +190,7 @@ index 85f31b7..7314584 100644 filegroup( name = "d8_files", srcs = [ -@@ -4567,16 +4579,9 @@ cc_library( +@@ -4567,16 +4583,9 @@ cc_library( ], ) @@ -192,7 +209,8 @@ index 85f31b7..7314584 100644 + actual = "@simdutf//:simdutf", ) -@@ -4593,7 +4598,7 @@ v8_library( + v8_library( +@@ -4593,7 +4602,7 @@ v8_library( copts = ["-Wno-implicit-fallthrough"], icu_deps = [ ":icu/generated_torque_definitions_headers", @@ -201,7 +219,7 @@ index 85f31b7..7314584 100644 ], icu_srcs = [ ":generated_regexp_special_case", -@@ -4608,7 +4613,7 @@ v8_library( +@@ -4608,7 +4617,7 @@ v8_library( ], deps = [ ":lib_dragonbox", @@ -210,7 +228,7 @@ index 85f31b7..7314584 100644 ":lib_fp16", ":simdutf", ":v8_libbase", -@@ -4664,6 +4669,7 @@ alias( +@@ -4664,6 +4673,7 @@ alias( alias( name = "core_lib_icu", actual = "icu/v8", @@ -218,7 +236,7 @@ index 85f31b7..7314584 100644 ) v8_library( -@@ -4715,7 +4721,7 @@ v8_binary( +@@ -4715,7 +4725,7 @@ v8_binary( ], deps = [ ":v8_libbase", diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 8c225e4d3b..2fc585d7e9 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -596,6 +596,10 @@ install_release() { cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg" chmod 0755 "$stage_release/codex" chmod 0755 "$stage_release/codex-resources/rg" + if [ -f "$vendor_root/codex-resources/bwrap" ]; then + cp "$vendor_root/codex-resources/bwrap" "$stage_release/codex-resources/bwrap" + chmod 0755 "$stage_release/codex-resources/bwrap" + fi if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then rm -rf "$release_dir" @@ -611,7 +615,11 @@ release_dir_is_complete() { [ -d "$release_dir" ] && [ -x "$release_dir/codex" ] && [ -x "$release_dir/codex-resources/rg" ] && - [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] + [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] && + case "$expected_target" in + *linux*) [ -x "$release_dir/codex-resources/bwrap" ] ;; + *) true ;; + esac } update_current_link() { diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 5bbee755e9..2a0cd0028d 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -58,6 +58,16 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Retain temporary staging directories instead of deleting them.", ) + parser.add_argument( + "--allow-missing-native-component", + dest="allow_missing_native_components", + action="append", + default=[], + help=( + "Native component that may be absent from reused workflow artifacts. " + "Intended for CI compatibility only; release staging should not use this." + ), + ) return parser.parse_args() @@ -147,6 +157,8 @@ def main() -> int: packages = expand_packages(list(args.packages)) native_components = collect_native_components(packages) + allow_missing_native_components = set(args.allow_missing_native_components) + native_components_to_install = native_components - allow_missing_native_components vendor_temp_root: Path | None = None vendor_src: Path | None = None @@ -155,12 +167,12 @@ def main() -> int: final_messages = [] try: - if native_components: + if native_components_to_install: workflow_url, resolved_head_sha = resolve_workflow_url( args.release_version, args.workflow_url ) vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) - install_native_components(workflow_url, native_components, vendor_temp_root) + install_native_components(workflow_url, native_components_to_install, vendor_temp_root) vendor_src = vendor_temp_root / "vendor" if resolved_head_sha: @@ -185,6 +197,9 @@ def main() -> int: if vendor_src is not None: cmd.extend(["--vendor-src", str(vendor_src)]) + for component in sorted(allow_missing_native_components): + cmd.extend(["--allow-missing-native-component", component]) + try: run_command(cmd) finally: diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 6b85ba66c2..f0961df1d0 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -3,14 +3,21 @@ This directory wires the `v8` crate to exact-version Bazel inputs. Bazel consumer builds use: -- upstream `denoland/rusty_v8` release archives on Windows -- source-built V8 archives on Darwin, GNU Linux, and musl Linux +- upstream `denoland/rusty_v8` release archives on Windows MSVC +- source-built V8 archives on Darwin, GNU Linux, musl Linux, and Windows GNU - `openai/codex` release assets for published musl release pairs Cargo builds still use prebuilt `rusty_v8` archives by default. Only Bazel overrides `RUSTY_V8_ARCHIVE`/`RUSTY_V8_SRC_BINDING_PATH` in `MODULE.bazel` to select source-built local archives for its consumer builds. +Source-built Bazel V8 artifacts enable V8's in-process sandbox by default, and +the Bazel `v8` crate feature selection tracks those targets. A full consumer +rollout still needs matching sandbox-enabled archives for every non-source-built +target. Until that artifact migration lands, the rusty_v8 publishing workflows +use `--config=v8-release-compat` to preserve the current non-sandboxed release +artifact contract. + Current pinned versions: - Rust crate: `v8 = =146.4.0`