From 456c14df4ee4769eed9a524ef923d01cb8258131 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 8 Jun 2026 11:58:30 -0700 Subject: [PATCH] Remove non-Python goal changes --- ...hive-release-symbols-and-strip-binaries.sh | 119 ++++ .github/workflows/bazel.yml | 16 +- .github/workflows/rust-ci-full.yml | 64 +- .github/workflows/rust-ci.yml | 4 +- .../rust-release-argument-comment-lint.yml | 4 +- .github/workflows/rust-release-windows.yml | 65 +- .github/workflows/rust-release.yml | 47 +- .github/workflows/sdk.yml | 20 +- codex-rs/Cargo.toml | 7 +- .../analytics/src/analytics_client_tests.rs | 8 + codex-rs/analytics/src/events.rs | 4 + codex-rs/analytics/src/facts.rs | 2 + .../schema/json/ClientRequest.json | 4 - .../codex_app_server_protocol.schemas.json | 4 - .../codex_app_server_protocol.v2.schemas.json | 4 - .../schema/json/v2/TurnStartParams.json | 4 - .../schema/typescript/v2/TurnStartParams.ts | 3 - .../src/protocol/v2/tests.rs | 14 +- .../src/protocol/v2/turn.rs | 3 - codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/connection_cleanup.rs | 49 ++ .../app-server/src/connection_rpc_gate.rs | 43 +- codex-rs/app-server/src/lib.rs | 21 +- codex-rs/app-server/src/message_processor.rs | 16 +- .../src/message_processor_tracing_tests.rs | 1 - codex-rs/app-server/src/request_processors.rs | 4 +- .../external_agent_config_processor.rs | 143 +---- .../external_agent_session_import.rs | 260 ++++++++ .../src/request_processors/turn_processor.rs | 50 +- .../app-server/src/request_serialization.rs | 2 +- .../tests/suite/v2/external_agent_config.rs | 92 +++ .../app-server/tests/suite/v2/turn_start.rs | 607 ------------------ codex-rs/cli/src/marketplace_cmd.rs | 51 +- codex-rs/cli/src/plugin_cmd.rs | 4 +- codex-rs/cli/tests/plugin_cli.rs | 108 +++- codex-rs/core/src/client.rs | 14 +- codex-rs/core/src/codex_delegate.rs | 1 - codex-rs/core/src/codex_delegate_tests.rs | 1 - codex-rs/core/src/codex_thread.rs | 9 +- codex-rs/core/src/compact.rs | 22 +- codex-rs/core/src/compact_remote.rs | 34 +- codex-rs/core/src/compact_remote_v2.rs | 93 ++- codex-rs/core/src/session/handlers.rs | 144 +---- codex-rs/core/src/session/initial_goal.rs | 106 --- codex-rs/core/src/session/mod.rs | 43 -- codex-rs/core/src/session/session.rs | 3 - codex-rs/core/src/session/tests.rs | 7 - codex-rs/core/src/session/turn_context.rs | 154 ++--- codex-rs/core/src/tools/registry.rs | 11 + codex-rs/core/tests/responses_headers.rs | 4 + codex-rs/core/tests/suite/compact_remote.rs | 4 + codex-rs/core/tests/suite/initial_goal.rs | 194 ------ codex-rs/core/tests/suite/mod.rs | 1 - ...pi_auth_prompt_cache_key_request_diff.snap | 3 +- ...ce_tier_prompt_cache_key_request_diff.snap | 3 +- codex-rs/core/tests/suite/sqlite_state.rs | 88 +++ codex-rs/exec/src/lib.rs | 1 - .../ext/extension-api/src/contributors.rs | 4 - .../src/contributors/initial_goal.rs | 39 -- codex-rs/ext/extension-api/src/lib.rs | 3 - codex-rs/ext/extension-api/src/registry.rs | 15 - codex-rs/ext/goal/src/api.rs | 65 -- codex-rs/ext/goal/src/extension.rs | 57 -- codex-rs/ext/goal/src/runtime.rs | 32 - codex-rs/ext/web-search/src/output.rs | 6 +- .../external-agent-sessions/src/export.rs | 191 +++--- .../external-agent-sessions/src/ledger.rs | 67 +- .../src/ledger_tests.rs | 85 +++ codex-rs/external-agent-sessions/src/lib.rs | 192 +++--- .../external-agent-sessions/src/records.rs | 155 +++-- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/mcp-server/src/message_processor.rs | 1 - codex-rs/protocol/src/protocol.rs | 7 - codex-rs/tools/src/tool_output.rs | 10 + codex-rs/tui/src/app_server_session.rs | 1 - .../src/openai_codex/generated/v2_all.py | 6 - 76 files changed, 1663 insertions(+), 2065 deletions(-) create mode 100755 .github/scripts/archive-release-symbols-and-strip-binaries.sh create mode 100644 codex-rs/app-server/src/connection_cleanup.rs create mode 100644 codex-rs/app-server/src/request_processors/external_agent_session_import.rs delete mode 100644 codex-rs/core/src/session/initial_goal.rs delete mode 100644 codex-rs/core/tests/suite/initial_goal.rs delete mode 100644 codex-rs/ext/extension-api/src/contributors/initial_goal.rs create mode 100644 codex-rs/external-agent-sessions/src/ledger_tests.rs diff --git a/.github/scripts/archive-release-symbols-and-strip-binaries.sh b/.github/scripts/archive-release-symbols-and-strip-binaries.sh new file mode 100755 index 0000000000..3e5894bb99 --- /dev/null +++ b/.github/scripts/archive-release-symbols-and-strip-binaries.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: archive-release-symbols-and-strip-binaries.sh \ + --target \ + --artifact-name \ + --release-dir \ + --archive-dir \ + --binaries "" +EOF +} + +target="" +artifact_name="" +release_dir="" +archive_dir="" +binaries="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + target="${2:?--target requires a value}" + shift 2 + ;; + --artifact-name) + artifact_name="${2:?--artifact-name requires a value}" + shift 2 + ;; + --release-dir) + release_dir="${2:?--release-dir requires a value}" + shift 2 + ;; + --archive-dir) + archive_dir="${2:?--archive-dir requires a value}" + shift 2 + ;; + --binaries) + binaries="${2:?--binaries requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unexpected argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then + usage >&2 + exit 1 +fi + +symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}" +symbols_dir="${symbols_root}/codex-symbols-${artifact_name}" +archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz" +rm -rf "$symbols_root" +mkdir -p "$symbols_dir" "$archive_dir" +read -r -a binary_names <<< "$binaries" + +case "$target" in + *apple-darwin) + for binary in "${binary_names[@]}"; do + binary_path="${release_dir%/}/${binary}" + dsym_path="${binary_path}.dSYM" + if [[ ! -f "$binary_path" ]]; then + echo "Binary $binary_path not found" >&2 + exit 1 + fi + if [[ ! -d "$dsym_path" ]]; then + echo "dSYM $dsym_path not found" >&2 + exit 1 + fi + + cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM" + strip -S -x "$binary_path" + done + ;; + *linux*) + objcopy_bin="${OBJCOPY:-objcopy}" + strip_bin="${STRIP:-strip}" + for binary in "${binary_names[@]}"; do + binary_path="${release_dir%/}/${binary}" + debug_path="${symbols_dir}/${binary}.debug" + if [[ ! -f "$binary_path" ]]; then + echo "Binary $binary_path not found" >&2 + exit 1 + fi + + "$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path" + "$strip_bin" --strip-debug --strip-unneeded "$binary_path" + "$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path" + done + ;; + *windows*) + for binary in "${binary_names[@]}"; do + pdb_path="${release_dir%/}/${binary}.pdb" + if [[ ! -f "$pdb_path" ]]; then + echo "PDB $pdb_path not found" >&2 + exit 1 + fi + + cp "$pdb_path" "${symbols_dir}/${binary}.pdb" + done + ;; + *) + echo "No symbols packaging support for target: $target" >&2 + exit 1 + ;; +esac + +rm -f "$archive_path" +tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}" diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 809053069a..ffda87b8a5 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -152,8 +152,8 @@ jobs: - 3 - 4 runs-on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 environment: name: bazel @@ -267,8 +267,8 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' timeout-minutes: 40 runs-on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm (native main) environment: name: bazel @@ -357,8 +357,8 @@ jobs: - os: windows-latest target: x86_64-pc-windows-gnullvm runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 runs-on: ${{ matrix.runs_on || matrix.os }} name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }} environment: @@ -453,8 +453,8 @@ jobs: - os: windows-latest target: x86_64-pc-windows-gnullvm runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 runs-on: ${{ matrix.runs_on || matrix.os }} name: Verify release build on ${{ matrix.os }} for ${{ matrix.target }} environment: diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index d22531baae..7ad1d4b3ad 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -112,8 +112,8 @@ jobs: - name: Windows runner: windows-x64 runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -185,38 +185,38 @@ jobs: target: x86_64-unknown-linux-musl profile: dev runs_on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 - runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu profile: dev runs_on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl profile: dev runs_on: - group: codex-runners - labels: codex-linux-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-arm64 - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu profile: dev runs_on: - group: codex-runners - labels: codex-linux-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc profile: dev runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc profile: dev runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 # Also run representative release builds on Mac and Linux because # there could be release-only build errors we want to catch. @@ -229,26 +229,26 @@ jobs: target: x86_64-unknown-linux-musl profile: release runs_on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl profile: release runs_on: - group: codex-runners - labels: codex-linux-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc profile: release runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc profile: release runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -479,8 +479,8 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: ubuntu-24.04 - runner_group: codex-runners - runner_labels: codex-linux-x64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-linux-x64 target: x86_64-unknown-linux-gnu profile: ci-test artifact_id: linux-x64-remote @@ -493,8 +493,8 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: ubuntu-24.04-arm - runner_group: codex-runners - runner_labels: codex-linux-arm64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-gnu profile: ci-test artifact_id: linux-arm64 @@ -506,8 +506,8 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: windows-x64 - runner_group: codex-runners - runner_labels: codex-windows-x64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-windows-x64 target: x86_64-pc-windows-msvc profile: ci-test artifact_id: windows-x64 @@ -519,11 +519,11 @@ jobs: uses: ./.github/workflows/rust-ci-full-nextest-platform.yml with: runner: windows-arm64 - runner_group: codex-runners - runner_labels: codex-windows-arm64 + runner_group: ${{ github.event.repository.name }}-runners + runner_labels: ${{ github.event.repository.name }}-windows-arm64 archive_runner: windows-x64 - archive_runner_group: codex-runners - archive_runner_labels: codex-windows-x64 + archive_runner_group: ${{ github.event.repository.name }}-runners + archive_runner_labels: ${{ github.event.repository.name }}-windows-x64 target: aarch64-pc-windows-msvc profile: ci-test artifact_id: windows-arm64 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 4528f4d963..4cf6d6e37a 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -176,8 +176,8 @@ jobs: runner: windows-x64 timeout_minutes: 30 runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index 7f5ad01aa5..5bf1121925 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -57,8 +57,8 @@ jobs: runner_binary: argument-comment-lint.exe cargo_dylint_binary: cargo-dylint.exe runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 14d38b9db5..e7574d85d2 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -29,43 +29,43 @@ jobs: bundle: primary binaries: "codex codex-responses-api-proxy" runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: primary binaries: "codex codex-responses-api-proxy" runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: helpers binaries: "codex-windows-sandbox-setup codex-command-runner" runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: helpers binaries: "codex-windows-sandbox-setup codex-command-runner" runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: app-server binaries: "codex-app-server" runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: app-server binaries: "codex-app-server" runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -112,10 +112,22 @@ jobs: - name: Stage Windows binaries shell: bash run: | - output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" + release_dir="target/${{ matrix.target }}/release" + output_dir="$release_dir/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" + pdb_name="${binary//-/_}" + pdb_path="$release_dir/${pdb_name}.pdb" + if [[ ! -f "$pdb_path" ]]; then + pdb_path="$release_dir/${binary}.pdb" + fi + if [[ ! -f "$pdb_path" ]]; then + echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2 + exit 1 + fi + + cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe" + cp "$pdb_path" "$output_dir/${binary}.pdb" done - name: Upload Windows binaries @@ -150,13 +162,13 @@ jobs: - runner: windows-x64 target: x86_64-pc-windows-msvc runs_on: - group: codex-runners - labels: codex-windows-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc runs_on: - group: codex-runners - labels: codex-windows-arm64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -201,6 +213,23 @@ jobs: account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + - name: Build symbols archive + shell: bash + run: | + bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ + --target "${{ matrix.target }}" \ + --artifact-name "${{ matrix.target }}" \ + --release-dir "target/${{ matrix.target }}/release" \ + --archive-dir "symbols-dist/${{ matrix.target }}" \ + --binaries "${WINDOWS_BINARIES}" + + - name: Upload symbols archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.target }}-symbols + path: codex-rs/symbols-dist/${{ matrix.target }}/* + if-no-files-found: error + - name: Stage artifacts shell: bash run: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f95d1216f8..b821700b49 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -64,6 +64,8 @@ jobs: run: working-directory: codex-rs env: + # macOS release packages archive packed dSYM bundles before stripping. + CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }} # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -99,25 +101,25 @@ jobs: binaries: "codex-app-server" build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl + - runner: ${{ github.event.repository.name }}-linux-x64-xl target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: codex-linux-x64-xl + - runner: ${{ github.event.repository.name }}-linux-x64-xl target: x86_64-unknown-linux-musl bundle: app-server artifact_name: x86_64-unknown-linux-musl-app-server binaries: "codex-app-server" build_dmg: "false" - - runner: codex-linux-arm64 + - runner: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: codex-linux-arm64 + - runner: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-musl bundle: app-server artifact_name: aarch64-unknown-linux-musl-app-server @@ -163,7 +165,7 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} @@ -222,6 +224,10 @@ jobs: exit 1 fi + # Codex embeds this digest at build time and verifies the bundled + # bwrap resource before use. Strip bwrap before hashing so the digest + # covers the exact bytes that the release packages. + strip --strip-debug --strip-unneeded "$bwrap_path" digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" echo "Built bwrap ${bwrap_path} with sha256:${digest}" @@ -235,6 +241,11 @@ jobs: fi build_args=() for binary in ${{ matrix.binaries }}; do + # bwrap was built, finalized, and hashed before this build so + # Codex can embed the digest of the bytes that will be packaged. + if [[ "$binary" == "bwrap" ]]; then + continue + fi build_args+=(--bin "$binary") done cargo build --target "$target" --release --timings "${build_args[@]}" @@ -246,6 +257,32 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn + - name: Build symbols archive and strip binaries + shell: bash + run: | + binaries=() + for binary in ${{ matrix.binaries }}; do + # bwrap is already stripped before hashing. Its symbols are not + # useful enough to justify a separate pre-Codex symbols pass. + if [[ "$binary" == "bwrap" ]]; then + continue + fi + binaries+=("$binary") + done + bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ + --target "${{ matrix.target }}" \ + --artifact-name "${{ matrix.artifact_name }}" \ + --release-dir "target/${{ matrix.target }}/release" \ + --archive-dir "symbols-dist/${{ matrix.artifact_name }}" \ + --binaries "${binaries[*]}" + + - name: Upload symbols archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-symbols + path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/* + if-no-files-found: error + - if: ${{ runner.os == 'macOS' }} name: Stage unsigned macOS artifacts shell: bash diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 7cc07a0459..3cf6d7c8c3 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -8,8 +8,8 @@ on: jobs: python-sdk: runs-on: - group: codex-runners - labels: codex-linux-x64 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 timeout-minutes: 10 steps: - name: Checkout repository @@ -43,9 +43,9 @@ jobs: sdks: runs-on: - group: codex-runners - labels: codex-linux-x64 - timeout-minutes: 60 + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-linux-x64 + timeout-minutes: 10 environment: name: bazel deployment: false @@ -141,16 +141,6 @@ jobs: set -euo pipefail "${CODEX_EXEC_PATH}" --version - - name: Test Python goal turns against source Codex - shell: bash - run: | - set -euo pipefail - python3 -m venv /tmp/codex-python-sdk-uv - /tmp/codex-python-sdk-uv/bin/python -m pip install uv==0.11.3 - cd sdk/python - /tmp/codex-python-sdk-uv/bin/uv sync --extra dev --frozen - /tmp/codex-python-sdk-uv/bin/uv run --extra dev pytest tests/test_app_server_goal_turns.py - - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0e3aba3288..3bb23ba80a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -501,10 +501,11 @@ strip = "symbols" [profile.release] lto = "thin" +debug = "line-tables-only" split-debuginfo = "off" -# Because we bundle some of these executables with the TypeScript CLI, we -# remove everything to make the binary as small as possible. -strip = "symbols" +# Keep release binaries symbolicateable until packaging has archived the +# sidecar symbols and stripped the binaries. +strip = false # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f4cd18da1e..a3b6dfa949 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1253,6 +1253,8 @@ fn compaction_event_serializes_expected_shape() { error: None, active_context_tokens_before: 120_000, active_context_tokens_after: 18_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 106, duration_ms: Some(6543), @@ -1301,6 +1303,8 @@ fn compaction_event_serializes_expected_shape() { "error": null, "active_context_tokens_before": 120000, "active_context_tokens_after": 18000, + "retained_image_count": null, + "compaction_summary_tokens": null, "started_at": 100, "completed_at": 106, "duration_ms": 6543 @@ -1821,6 +1825,8 @@ async fn compaction_event_ingests_custom_fact() { error: Some("context limit exceeded".to_string()), active_context_tokens_before: 131_000, active_context_tokens_after: 131_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 101, duration_ms: Some(1200), @@ -2743,6 +2749,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { error: None, active_context_tokens_before: 131_000, active_context_tokens_after: 64_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 101, duration_ms: Some(1200), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index f0fcddd27f..019d59776a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -758,6 +758,8 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, + pub(crate) retained_image_count: Option, + pub(crate) compaction_summary_tokens: Option, pub(crate) started_at: u64, pub(crate) completed_at: u64, pub(crate) duration_ms: Option, @@ -970,6 +972,8 @@ pub(crate) fn codex_compaction_event_params( error: input.error, active_context_tokens_before: input.active_context_tokens_before, active_context_tokens_after: input.active_context_tokens_after, + retained_image_count: input.retained_image_count, + compaction_summary_tokens: input.compaction_summary_tokens, started_at: input.started_at, completed_at: input.completed_at, duration_ms: input.duration_ms, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 38af5ed8b8..c9d245ce90 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -434,6 +434,8 @@ pub struct CodexCompactionEvent { pub error: Option, pub active_context_tokens_before: i64, pub active_context_tokens_after: i64, + pub retained_image_count: Option, + pub compaction_summary_tokens: Option, pub started_at: u64, pub completed_at: u64, pub duration_ms: Option, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index c54bb99027..6d9be4ec02 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4071,10 +4071,6 @@ ], "description": "Override the reasoning effort for this turn and subsequent turns." }, - "goal": { - "description": "Replace the thread's active goal with an objective derived from this turn's text input.", - "type": "boolean" - }, "input": { "items": { "$ref": "#/definitions/UserInput" 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 e492b2187e..1873ed317e 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 @@ -18862,10 +18862,6 @@ ], "description": "Override the reasoning effort for this turn and subsequent turns." }, - "goal": { - "description": "Replace the thread's active goal with an objective derived from this turn's text input.", - "type": "boolean" - }, "input": { "items": { "$ref": "#/definitions/v2/UserInput" 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 21119b85e7..38c74b1859 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 @@ -16679,10 +16679,6 @@ ], "description": "Override the reasoning effort for this turn and subsequent turns." }, - "goal": { - "description": "Replace the thread's active goal with an objective derived from this turn's text input.", - "type": "boolean" - }, "input": { "items": { "$ref": "#/definitions/UserInput" 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 4c5ad6957f..070944a28b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -533,10 +533,6 @@ ], "description": "Override the reasoning effort for this turn and subsequent turns." }, - "goal": { - "description": "Replace the thread's active goal with an objective derived from this turn's text input.", - "type": "boolean" - }, "input": { "items": { "$ref": "#/definitions/UserInput" 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 4eadbdb331..afe1ac6d94 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -11,9 +11,6 @@ import type { SandboxPolicy } from "./SandboxPolicy"; import type { UserInput } from "./UserInput"; export type TurnStartParams = {threadId: string, clientUserMessageId?: string | null, input: Array, /** - * Replace the thread's active goal with an objective derived from this turn's text input. - */ -goal?: boolean, /** * Override the working directory for this turn and subsequent turns. */ cwd?: string | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 98d5b808b8..d064795f2b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3629,10 +3629,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { "serviceTier": null })) .expect("params should deserialize"); - assert_eq!( - (params.service_tier.clone(), params.goal), - (Some(None), false) - ); + assert_eq!(params.service_tier, Some(None)); let serialized = serde_json::to_value(¶ms).expect("params should serialize"); assert_eq!( @@ -3644,7 +3641,6 @@ fn turn_start_params_preserve_explicit_null_service_tier() { thread_id: "thread_123".to_string(), client_user_message_id: None, input: vec![], - goal: false, responsesapi_client_metadata: None, additional_context: None, environments: None, @@ -3664,13 +3660,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { }; let serialized_without_override = serde_json::to_value(&without_override).expect("params should serialize"); - assert_eq!( - ( - serialized_without_override.get("serviceTier"), - serialized_without_override.get("goal"), - ), - (None, None) - ); + assert_eq!(serialized_without_override.get("serviceTier"), None); } #[test] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index e61ca5f83f..3bac7bf7ec 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -68,9 +68,6 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub client_user_message_id: Option, pub input: Vec, - /// Replace the thread's active goal with an objective derived from this turn's text input. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub goal: bool, /// Optional turn-scoped Responses API client metadata. #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7487ae53ce..a06adf03e7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -158,7 +158,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Set `goal: true` to replace the persisted thread goal using the ordered, non-empty text inputs as its objective before generation starts; ephemeral threads and input without text reject goal turns. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -659,8 +659,6 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. -Set `goal` to `true` to run the request as a goal. The server trims each non-empty text input, joins them in order with a blank line to form the objective, replaces any existing goal, and preserves the original input for the turn. Goal turns require an idle persisted thread, the goals feature to be enabled, and a collaboration mode other than plan mode. - `approvalsReviewer` accepts: - `"user"` — default. Review approval requests directly in the client. diff --git a/codex-rs/app-server/src/connection_cleanup.rs b/codex-rs/app-server/src/connection_cleanup.rs new file mode 100644 index 0000000000..529020fe3f --- /dev/null +++ b/codex-rs/app-server/src/connection_cleanup.rs @@ -0,0 +1,49 @@ +use std::future::Future; +use std::future::pending; + +use tokio::task::JoinError; +use tokio::task::JoinSet; +use tracing::warn; + +pub(crate) struct ConnectionCleanupTasks { + tasks: JoinSet<()>, +} + +impl ConnectionCleanupTasks { + pub(crate) fn new() -> Self { + Self { + tasks: JoinSet::new(), + } + } + + pub(crate) fn spawn(&mut self, future: impl Future + Send + 'static) { + self.tasks.spawn(future); + } + + pub(crate) async fn reap_next(&mut self) { + if self.tasks.is_empty() { + pending::<()>().await; + } + if let Some(result) = self.tasks.join_next().await { + log_cleanup_result(result); + } + } + + pub(crate) async fn drain(&mut self) { + while let Some(result) = self.tasks.join_next().await { + log_cleanup_result(result); + } + } + + pub(crate) fn abort(&mut self) { + self.tasks.abort_all(); + } +} + +fn log_cleanup_result(result: Result<(), JoinError>) { + if let Err(err) = result + && !err.is_cancelled() + { + warn!("connection cleanup task failed: {err}"); + } +} diff --git a/codex-rs/app-server/src/connection_rpc_gate.rs b/codex-rs/app-server/src/connection_rpc_gate.rs index 12fed79b36..fb2aedd352 100644 --- a/codex-rs/app-server/src/connection_rpc_gate.rs +++ b/codex-rs/app-server/src/connection_rpc_gate.rs @@ -38,12 +38,14 @@ impl ConnectionRpcGate { drop(token); } + pub(crate) async fn close(&self) { + let mut accepting = self.accepting.lock().await; + *accepting = false; + self.tasks.close(); + } + pub(crate) async fn shutdown(&self) { - { - let mut accepting = self.accepting.lock().await; - *accepting = false; - self.tasks.close(); - } + self.close().await; self.tasks.wait().await; } @@ -90,9 +92,9 @@ mod tests { } #[tokio::test] - async fn run_drops_future_without_polling_after_shutdown() { + async fn run_drops_future_without_polling_after_close() { let gate = ConnectionRpcGate::new(); - gate.shutdown().await; + gate.close().await; let polled = Arc::new(AtomicBool::new(/*v*/ false)); let polled_clone = Arc::clone(&polled); @@ -105,6 +107,33 @@ mod tests { assert!(!gate.is_accepting().await); } + #[tokio::test] + async fn close_returns_while_started_run_remains_active() { + let gate = Arc::new(ConnectionRpcGate::new()); + let (started_tx, started_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + let gate_for_run = Arc::clone(&gate); + let run_task = tokio::spawn(async move { + gate_for_run + .run(async move { + started_tx.send(()).expect("receiver should be open"); + let _ = finish_rx.await; + }) + .await; + }); + + started_rx.await.expect("run should start"); + gate.close().await; + assert!(!gate.is_accepting().await); + assert_eq!(gate.inflight_count(), 1); + + finish_tx + .send(()) + .expect("running future should be waiting"); + run_task.await.expect("run task should complete"); + gate.shutdown().await; + } + #[tokio::test] async fn shutdown_waits_for_started_run_to_finish() { let gate = Arc::new(ConnectionRpcGate::new()); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0689a79f00..b6f0b8c859 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -20,6 +20,7 @@ use std::sync::atomic::AtomicBool; use crate::analytics_utils::analytics_events_client_from_config; use crate::config_manager::ConfigManager; +use crate::connection_cleanup::ConnectionCleanupTasks; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; @@ -81,6 +82,7 @@ mod command_exec; mod config; mod config_manager; mod config_manager_service; +mod connection_cleanup; mod connection_rpc_gate; mod dynamic_tools; mod error_code; @@ -819,6 +821,7 @@ pub async fn run_main_with_transport_options( let mut thread_created_rx = processor.thread_created_receiver(); let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); let mut connections = HashMap::::new(); + let mut connection_cleanup_tasks = ConnectionCleanupTasks::new(); let mut remote_control_status_rx = remote_control_handle.status_receiver(); let mut remote_control_status = remote_control_status_rx.borrow().clone(); let transport_shutdown_token = transport_shutdown_token.clone(); @@ -906,14 +909,20 @@ pub async fn run_main_with_transport_options( let Some(connection_state) = connections.remove(&connection_id) else { continue; }; - if outbound_control_tx + connection_state.session.rpc_gate.close().await; + let outbound_closed = outbound_control_tx .send(OutboundControlEvent::Closed { connection_id }) .await - .is_err() - { + .is_ok(); + let processor = Arc::clone(&processor); + connection_cleanup_tasks.spawn(async move { + processor + .connection_closed(connection_id, &connection_state.session) + .await; + }); + if !outbound_closed { break; } - processor.connection_closed(connection_id, &connection_state.session).await; if shutdown_when_no_connections && connections.is_empty() { break; } @@ -1010,6 +1019,7 @@ pub async fn run_main_with_transport_options( } } } + _ = connection_cleanup_tasks.reap_next() => {} changed = remote_control_status_rx.changed() => { if changed.is_err() { continue; @@ -1062,8 +1072,11 @@ pub async fn run_main_with_transport_options( .map(|connection_state| connection_state.session.rpc_gate.shutdown()), ) .await; + connection_cleanup_tasks.drain().await; processor.drain_background_tasks().await; processor.shutdown_threads().await; + } else { + connection_cleanup_tasks.abort(); } info!("processor task exited (channel closed)"); } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index b7e0ff258d..84d3d88dda 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -91,6 +91,7 @@ use tokio_util::sync::CancellationToken; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); +const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); #[derive(Clone)] struct ExternalAuthRefreshBridge { @@ -474,6 +475,7 @@ impl MessageProcessor { let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( outgoing.clone(), Arc::clone(&thread_manager), + Arc::clone(&thread_store), config_manager.clone(), config_processor.clone(), arg0_paths, @@ -723,7 +725,19 @@ impl MessageProcessor { connection_id: ConnectionId, session_state: &ConnectionSessionState, ) { - session_state.rpc_gate.shutdown().await; + if timeout( + CONNECTION_RPC_DRAIN_TIMEOUT, + session_state.rpc_gate.shutdown(), + ) + .await + .is_err() + { + tracing::warn!( + ?connection_id, + timeout_seconds = CONNECTION_RPC_DRAIN_TIMEOUT.as_secs(), + "timed out waiting for connection RPCs to drain" + ); + } self.outgoing.connection_closed(connection_id).await; self.fs_processor.connection_closed(connection_id).await; self.command_exec_processor 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 53f8512e3b..771b1fd128 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -658,7 +658,6 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { text: "hello".to_string(), text_elements: Vec::new(), }], - goal: false, responsesapi_client_metadata: None, additional_context: None, cwd: None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8817619b4e..79da49fba4 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -362,7 +362,6 @@ use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; 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::TrustLevel; @@ -387,7 +386,6 @@ use codex_protocol::protocol::ConversationTextParams; use codex_protocol::protocol::EventMsg; #[cfg(test)] use codex_protocol::protocol::GitInfo as CoreGitInfo; -use codex_protocol::protocol::InitialGoal; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::Op; @@ -404,7 +402,6 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::W3cTraceContext; -use codex_protocol::protocol::validate_thread_goal_objective; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput as CoreInputItem; use codex_rmcp_client::perform_oauth_login_return_url; @@ -464,6 +461,7 @@ mod command_exec_processor; mod config_processor; mod environment_processor; mod external_agent_config_processor; +mod external_agent_session_import; mod feedback_doctor_report; mod feedback_processor; mod fs_processor; 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 87956182d0..5d4c0594eb 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 @@ -26,53 +26,47 @@ use codex_app_server_protocol::MigrationDetails; use codex_app_server_protocol::PluginsMigration; use codex_app_server_protocol::ServerNotification; use codex_arg0::Arg0DispatchPaths; -use codex_core::StartThreadOptions; use codex_core::ThreadManager; -use codex_core::config::ConfigOverrides; use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration; -use codex_external_agent_sessions::ImportedExternalAgentSession; -use codex_external_agent_sessions::PendingSessionImport; -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_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadStore; use std::collections::HashSet; use std::path::PathBuf; -use tokio::sync::Semaphore; use super::ConfigRequestProcessor; +use super::external_agent_session_import::ExternalAgentSessionImporter; #[derive(Clone)] pub(crate) struct ExternalAgentConfigRequestProcessor { outgoing: Arc, - codex_home: PathBuf, migration_service: ExternalAgentConfigService, - session_import_permits: Arc, + session_importer: ExternalAgentSessionImporter, thread_manager: Arc, - config_manager: ConfigManager, config_processor: ConfigRequestProcessor, - arg0_paths: Arg0DispatchPaths, } impl ExternalAgentConfigRequestProcessor { pub(crate) fn new( outgoing: Arc, thread_manager: Arc, + thread_store: Arc, config_manager: ConfigManager, config_processor: ConfigRequestProcessor, arg0_paths: Arg0DispatchPaths, codex_home: PathBuf, ) -> Self { + let session_importer = ExternalAgentSessionImporter::new( + codex_home.clone(), + Arc::clone(&thread_manager), + thread_store, + config_manager, + arg0_paths, + ); Self { outgoing, - migration_service: ExternalAgentConfigService::new(codex_home.clone()), - codex_home, - session_import_permits: Arc::new(Semaphore::new(1)), + migration_service: ExternalAgentConfigService::new(codex_home), + session_importer, thread_manager, - config_manager, config_processor, - arg0_paths, } } @@ -207,42 +201,12 @@ impl ExternalAgentConfigRequestProcessor { return Ok(()); } - let session_import_permits = Arc::clone(&self.session_import_permits); - let session_processor = self.clone(); + let session_importer = self.session_importer.clone(); let plugin_processor = self.clone(); let outgoing = Arc::clone(&self.outgoing); let thread_manager = Arc::clone(&self.thread_manager); tokio::spawn(async move { - let session_imports = async move { - if !pending_session_imports.is_empty() { - let Ok(_session_import_permit) = session_import_permits.acquire_owned().await - else { - return; - }; - let pending_session_imports = session_processor - .prepare_validated_session_imports(pending_session_imports); - for pending_session_import in pending_session_imports { - match session_processor - .import_external_agent_session(pending_session_import.session) - .await - { - Ok(imported_thread_id) => { - session_processor.record_imported_session( - &pending_session_import.source_path, - imported_thread_id, - ); - } - Err(error) => { - tracing::warn!( - error = %error.message, - path = %pending_session_import.source_path.display(), - "external agent session import failed" - ); - } - } - } - } - }; + let session_imports = session_importer.import_sessions(pending_session_imports); let plugin_imports = async move { for pending_plugin_import in pending_plugin_imports { match plugin_processor @@ -274,65 +238,6 @@ impl ExternalAgentConfigRequestProcessor { Ok(()) } - async fn import_external_agent_session( - &self, - session: ImportedExternalAgentSession, - ) -> Result { - let ImportedExternalAgentSession { - cwd, - title, - rollout_items, - } = session; - let config = self - .config_manager - .load_with_overrides( - /*request_overrides*/ None, - ConfigOverrides { - cwd: Some(PathBuf::from(cwd.to_string_lossy().into_owned())), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }, - ) - .await - .map_err(|err| { - internal_error(format!("failed to load imported session config: {err}")) - })?; - let environments = self - .thread_manager - .default_environment_selections(&config.cwd); - let imported_thread = self - .thread_manager - .start_thread_with_options(StartThreadOptions { - config, - initial_history: InitialHistory::Forked(rollout_items), - session_source: None, - thread_source: None, - dynamic_tools: Vec::new(), - metrics_service_name: None, - parent_trace: None, - environments, - }) - .await - .map_err(|err| internal_error(format!("failed to import session: {err}")))?; - if let Some(title) = title - && let Some(name) = codex_core::util::normalize_thread_name(&title) - { - imported_thread - .thread - .update_thread_metadata( - ThreadMetadataPatch { - name: Some(Some(name)), - ..Default::default() - }, - /*include_archived*/ false, - ) - .await - .map_err(|err| internal_error(format!("failed to name imported session: {err}")))?; - } - Ok(imported_thread.thread_id) - } - fn validate_pending_session_imports( &self, params: &ExternalAgentConfigImportParams, @@ -371,24 +276,6 @@ impl ExternalAgentConfigRequestProcessor { Ok(selected_sessions) } - fn prepare_validated_session_imports( - &self, - sessions: Vec, - ) -> Vec { - prepare_validated_session_imports(&self.codex_home, sessions) - } - - fn record_imported_session(&self, source_path: &std::path::Path, imported_thread_id: ThreadId) { - if let Err(err) = record_imported_session(&self.codex_home, source_path, imported_thread_id) - { - tracing::warn!( - error = %err, - path = %source_path.display(), - "external agent session import ledger update failed" - ); - } - } - async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs new file mode 100644 index 0000000000..163210f164 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -0,0 +1,260 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::Utc; +use codex_arg0::Arg0DispatchPaths; +use codex_core::ThreadManager; +use codex_core::config::ConfigOverrides; +use codex_external_agent_sessions::CompletedExternalAgentSessionImport; +use codex_external_agent_sessions::ExternalAgentSessionMigration; +use codex_external_agent_sessions::ImportedExternalAgentSession; +use codex_external_agent_sessions::PendingSessionImport; +use codex_external_agent_sessions::prepare_validated_session_import; +use codex_external_agent_sessions::record_completed_session_imports; +use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_rollout::is_persisted_rollout_item; +use codex_thread_store::AppendThreadItemsParams; +use codex_thread_store::CreateThreadParams; +use codex_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadPersistenceMetadata; +use codex_thread_store::ThreadStore; +use codex_thread_store::UpdateThreadMetadataParams; +use futures::StreamExt; +use tokio::sync::Semaphore; + +use crate::config_manager::ConfigManager; + +const SESSION_IMPORT_CONCURRENCY: usize = 5; + +#[derive(Clone)] +pub(super) struct ExternalAgentSessionImporter { + codex_home: PathBuf, + permits: Arc, + thread_manager: Arc, + thread_store: Arc, + config_manager: ConfigManager, + arg0_paths: Arg0DispatchPaths, +} + +impl ExternalAgentSessionImporter { + pub(super) fn new( + codex_home: PathBuf, + thread_manager: Arc, + thread_store: Arc, + config_manager: ConfigManager, + arg0_paths: Arg0DispatchPaths, + ) -> Self { + Self { + codex_home, + permits: Arc::new(Semaphore::new(1)), + thread_manager, + thread_store, + config_manager, + arg0_paths, + } + } + + pub(super) async fn import_sessions(&self, sessions: Vec) { + if sessions.is_empty() { + return; + } + let Ok(_permit) = self.permits.acquire().await else { + return; + }; + let import_results = futures::stream::iter(sessions) + .map(|session| { + let importer = self.clone(); + async move { importer.import_requested_session(session).await } + }) + .buffer_unordered(SESSION_IMPORT_CONCURRENCY); + futures::pin_mut!(import_results); + + let mut completed_imports = Vec::new(); + while let Some(result) = import_results.next().await { + match result { + Ok(Some(completed_import)) => completed_imports.push(completed_import), + Ok(None) => {} + Err(failure) => { + tracing::warn!( + error = %failure.message, + path = %failure.source_path.display(), + "external agent session import failed" + ); + } + } + } + if let Err(err) = record_completed_session_imports(&self.codex_home, completed_imports) { + tracing::warn!( + error = %err, + "external agent session import ledger update failed" + ); + } + } + + async fn import_requested_session( + &self, + session: ExternalAgentSessionMigration, + ) -> Result, SessionImportFailure> { + let source_path = session.path.clone(); + let Some(pending_import) = + self.prepare_session_import(session) + .await + .map_err(|message| SessionImportFailure { + source_path: source_path.clone(), + message, + })? + else { + return Ok(None); + }; + let imported_thread_id = + self.persist_session(pending_import.session) + .await + .map_err(|message| SessionImportFailure { + source_path: pending_import.source_path.clone(), + message, + })?; + Ok(Some(CompletedExternalAgentSessionImport { + source_path: pending_import.source_path, + source_content_sha256: pending_import.source_content_sha256, + imported_thread_id, + })) + } + + async fn prepare_session_import( + &self, + session: ExternalAgentSessionMigration, + ) -> Result, String> { + let codex_home = self.codex_home.clone(); + tokio::task::spawn_blocking(move || prepare_validated_session_import(&codex_home, session)) + .await + .map_err(|err| format!("external agent session preparation task failed: {err}"))? + .map_err(|err| format!("failed to prepare external agent session: {err}")) + } + + async fn persist_session( + &self, + session: ImportedExternalAgentSession, + ) -> Result { + let ImportedExternalAgentSession { + cwd, + title, + first_user_message, + mut rollout_items, + } = session; + let config = self + .config_manager + .load_with_overrides( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(cwd), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }, + ) + .await + .map_err(|err| format!("failed to load imported session config: {err}"))?; + let models_manager = self.thread_manager.get_models_manager(); + let model = models_manager + .get_default_model(&config.model, RefreshStrategy::Offline) + .await; + let model_info = models_manager + .get_model_info(model.as_str(), &config.to_models_manager_config()) + .await; + let thread_id = ThreadId::new(); + let source = self.thread_manager.session_source(); + let cwd = config.cwd.to_path_buf(); + let model_provider = config.model_provider_id.clone(); + let memory_mode = if config.memories.generate_memories { + ThreadMemoryMode::Enabled + } else { + ThreadMemoryMode::Disabled + }; + let now = Utc::now(); + let create_params = CreateThreadParams { + thread_id, + forked_from_id: None, + parent_thread_id: None, + source: source.clone(), + thread_source: None, + base_instructions: BaseInstructions { + text: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + }, + dynamic_tools: Vec::new(), + multi_agent_version: Some(MultiAgentVersion::V1), + metadata: ThreadPersistenceMetadata { + cwd: Some(cwd.clone()), + model_provider: model_provider.clone(), + memory_mode, + }, + }; + rollout_items.retain(is_persisted_rollout_item); + let title = title + .as_deref() + .and_then(codex_core::util::normalize_thread_name); + let metadata = ThreadMetadataPatch { + title, + preview: first_user_message.clone(), + model_provider: Some(model_provider), + created_at: Some(now), + updated_at: Some(now), + source: Some(source.clone()), + thread_source: Some(None), + agent_nickname: Some(source.get_nickname()), + agent_role: Some(source.get_agent_role()), + agent_path: Some(source.get_agent_path().map(Into::into)), + cwd: Some(cwd), + cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), + first_user_message, + memory_mode: Some(memory_mode), + ..Default::default() + }; + + self.thread_store + .create_thread(create_params) + .await + .map_err(|err| format!("failed to import session: {err}"))?; + if !rollout_items.is_empty() + && let Err(err) = self + .thread_store + .append_items(AppendThreadItemsParams { + thread_id, + items: rollout_items, + }) + .await + { + let _ = self.thread_store.discard_thread(thread_id).await; + return Err(format!("failed to import session: {err}")); + } + + self.thread_store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: metadata, + include_archived: false, + }) + .await + .map_err(|err| format!("failed to update imported session: {err}"))?; + self.thread_store + .persist_thread(thread_id) + .await + .map_err(|err| format!("failed to persist imported session: {err}"))?; + self.thread_store + .shutdown_thread(thread_id) + .await + .map_err(|err| format!("failed to shutdown imported session: {err}"))?; + Ok(thread_id) + } +} + +struct SessionImportFailure { + source_path: PathBuf, + message: String, +} 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 bc17413289..ce98a2e8e0 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -394,47 +394,6 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let initial_goal = if params.goal { - if !self.config.features.enabled(Feature::Goals) { - return Err(invalid_request("goals feature is disabled")); - } - if thread.rollout_path().is_none() { - return Err(invalid_request(format!( - "ephemeral thread does not support goals: {thread_id}" - ))); - } - if matches!(thread.agent_status().await, AgentStatus::Running) { - return Err(invalid_request( - "cannot start a goal while another turn is active", - )); - } - let goal_mode = params - .collaboration_mode - .as_ref() - .map(|mode| mode.mode) - .unwrap_or(thread.config_snapshot().await.collaboration_mode.mode); - if goal_mode == ModeKind::Plan { - return Err(invalid_request("goal turns do not support plan mode")); - } - let objective = params - .input - .iter() - .filter_map(|item| match item { - V2UserInput::Text { text, .. } => Some(text.trim()), - V2UserInput::Image { .. } - | V2UserInput::LocalImage { .. } - | V2UserInput::Skill { .. } - | V2UserInput::Mention { .. } => None, - }) - .filter(|text| !text.is_empty()) - .collect::>() - .join("\n\n"); - validate_thread_goal_objective(&objective).map_err(invalid_request)?; - Some(InitialGoal { objective }) - } else { - None - }; - let environment_selections = self.parse_environment_selections(params.environments)?; // Map v2 input items to core input items. @@ -477,20 +436,15 @@ impl TurnRequestProcessor { additional_context, thread_settings, }; - let trace = self.request_trace_context(&request_id).await; let turn_id = thread .submit_user_input_with_client_user_message_id( turn_op, - trace, + self.request_trace_context(&request_id).await, client_user_message_id, - initial_goal, ) .await .map_err(|err| { - let error = match err { - CodexErr::InvalidRequest(message) => invalid_request(message), - err => internal_error(format!("failed to start turn: {err}")), - }; + let error = internal_error(format!("failed to start turn: {err}")); self.track_error_response(&request_id, &error, /*error_type*/ None); error })?; diff --git a/codex-rs/app-server/src/request_serialization.rs b/codex-rs/app-server/src/request_serialization.rs index 0dd167b74d..77ecfc8f56 100644 --- a/codex-rs/app-server/src/request_serialization.rs +++ b/codex-rs/app-server/src/request_serialization.rs @@ -311,7 +311,7 @@ mod tests { let key = RequestSerializationQueueKey::Global("test"); let live_gate = gate(); let closed_gate = gate(); - closed_gate.shutdown().await; + closed_gate.close().await; let (tx, mut rx) = mpsc::unbounded_channel(); let (blocked_tx, blocked_rx) = oneshot::channel::<()>(); diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index fdb996c2f4..991e12dc78 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -435,6 +435,98 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { Ok(()) } +#[tokio::test] +async fn external_agent_config_import_does_not_initialize_required_mcp() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let mut config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + config.push_str( + r#" +[mcp_servers.required_broken] +command = "this-command-does-not-exist" +required = true +"#, + ); + std::fs::write(codex_home.path().join("config.toml"), config)?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": session_path, + "cwd": project_root, + "title": "first request" + }] + } + }] + })), + ) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data.len(), 1); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_agent_config_import_accepts_detected_session_payload_after_restart() -> Result<()> { 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 7a51d1b8c3..aed3c58697 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -39,13 +39,10 @@ use codex_app_server_protocol::JSONRPCResponse; 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::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::TextElement; -use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -63,7 +60,6 @@ use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; use codex_core::test_support::all_model_presets; use codex_features::FEATURES; use codex_features::Feature; -use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; @@ -73,7 +69,6 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; -use codex_state::StateRuntime; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -82,7 +77,6 @@ use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; -use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; use wiremock::ResponseTemplate; @@ -313,605 +307,6 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { Ok(()) } -#[tokio::test] -async fn turn_start_goal_replaces_existing_goal_and_continues() -> Result<()> { - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_assistant_message("msg-1", "Initial pass complete."), - responses::ev_completed("resp-1"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - responses::ev_completed("resp-2"), - ]), - responses::sse(vec![ - responses::ev_assistant_message("msg-2", "Goal complete."), - responses::ev_completed("resp-3"), - ]), - responses::sse(vec![ - responses::ev_assistant_message("msg-3", "Replacement initial pass complete."), - responses::ev_completed("resp-4"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-5"), - responses::ev_function_call( - "call-complete-replacement-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - responses::ev_completed("resp-5"), - ]), - responses::sse(vec![ - responses::ev_assistant_message("msg-4", "Replacement goal complete."), - responses::ev_completed("resp-6"), - ]), - ], - ) - .await; - - let codex_home = TempDir::new()?; - create_config_toml( - codex_home.path(), - &server.uri(), - "never", - &BTreeMap::from([(Feature::Goals, true)]), - )?; - let mut mcp = TestAppServer::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()), - config: Some(HashMap::from([( - "model_supports_reasoning_summaries".to_string(), - json!(true), - )])), - ..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 goal_cwd = codex_home.path().join("goal-workspace"); - std::fs::create_dir_all(&goal_cwd)?; - let output_schema = json!({ - "type": "object", - "properties": {"summary": {"type": "string"}}, - "required": ["summary"], - "additionalProperties": false, - }); - - let turn_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![ - V2UserInput::Text { - text: " Improve benchmark coverage ".to_string(), - text_elements: Vec::new(), - }, - V2UserInput::Text { - text: "Document the results".to_string(), - text_elements: Vec::new(), - }, - ], - goal: true, - cwd: Some(goal_cwd.clone()), - approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), - permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), - model: Some("mock-model".to_string()), - effort: Some(ReasoningEffort::High), - summary: Some(ReasoningSummary::Detailed), - output_schema: Some(output_schema.clone()), - ..Default::default() - }) - .await?; - let turn_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), - ) - .await??; - let TurnStartResponse { turn } = to_response(turn_resp)?; - - let notification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("thread/goal/updated"), - ) - .await??; - let notification: ServerNotification = notification.try_into()?; - let ServerNotification::ThreadGoalUpdated(notification) = notification else { - anyhow::bail!("expected thread goal update notification"); - }; - assert_eq!( - ( - notification.thread_id, - notification.turn_id, - notification.goal.objective, - notification.goal.status, - notification.goal.token_budget, - notification.goal.tokens_used, - notification.goal.time_used_seconds, - ), - ( - thread.id.clone(), - Some(turn.id.clone()), - "Improve benchmark coverage\n\nDocument the results".to_string(), - ThreadGoalStatus::Active, - None, - 0, - 0, - ) - ); - - let settings_notification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("thread/settings/updated"), - ) - .await??; - let settings_notification: ThreadSettingsUpdatedNotification = serde_json::from_value( - settings_notification - .params - .expect("thread/settings/updated params"), - )?; - assert_eq!( - ( - settings_notification.thread_id, - settings_notification.thread_settings.cwd, - settings_notification.thread_settings.approval_policy, - settings_notification.thread_settings.sandbox_policy, - settings_notification.thread_settings.model, - settings_notification.thread_settings.effort, - settings_notification.thread_settings.summary, - ), - ( - thread.id.clone(), - goal_cwd.clone().try_into()?, - codex_app_server_protocol::AskForApproval::Never, - codex_app_server_protocol::SandboxPolicy::DangerFullAccess, - "mock-model".to_string(), - Some(ReasoningEffort::High), - Some(ReasoningSummary::Detailed), - ) - ); - - let started: TurnStartedNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/started"), - ) - .await?? - .params - .expect("turn/started params"), - )?; - let completed: TurnCompletedNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await?? - .params - .expect("turn/completed params"), - )?; - assert_eq!( - ( - started.thread_id, - started.turn.id, - completed.thread_id, - completed.turn.id, - ), - ( - thread.id.clone(), - turn.id.clone(), - thread.id.clone(), - turn.id.clone(), - ) - ); - - let continuation_started: TurnStartedNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/started"), - ) - .await?? - .params - .expect("continuation turn/started params"), - )?; - let continuation_completed: TurnCompletedNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await?? - .params - .expect("continuation turn/completed params"), - )?; - let continuation_turn_id = continuation_started.turn.id.clone(); - assert_eq!( - ( - continuation_started.thread_id, - continuation_completed.thread_id, - continuation_completed.turn.id, - continuation_turn_id != turn.id, - ), - ( - thread.id.clone(), - thread.id.clone(), - continuation_turn_id, - true, - ) - ); - - let requests = response_mock.requests(); - let initial_request = requests.first().expect("initial model request"); - let initial_body = initial_request.body_json(); - let initial_user_texts = initial_request.message_input_texts("user"); - let original_input_texts = initial_user_texts - .iter() - .filter(|text| !text.starts_with("")) - .cloned() - .collect::>(); - let environment_context_has_cwd = initial_user_texts.iter().any(|text| { - text.starts_with("") - && text.contains(goal_cwd.to_string_lossy().as_ref()) - }); - assert_eq!( - ( - original_input_texts, - environment_context_has_cwd, - initial_body.get("model"), - initial_body.pointer("/reasoning/effort"), - initial_body.pointer("/reasoning/summary"), - initial_body.pointer("/text/format/schema"), - requests - .get(1) - .expect("continuation model request") - .message_input_texts("user") - .into_iter() - .any(|text| text.contains("Continue working toward the active thread goal.")), - ), - ( - vec![ - " Improve benchmark coverage ".to_string(), - "Document the results".to_string(), - ], - true, - Some(&json!("mock-model")), - Some(&json!("high")), - Some(&json!("detailed")), - Some(&output_schema), - true, - ) - ); - - let completed_goal_notification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("thread/goal/updated"), - ) - .await??; - let completed_goal_notification: ServerNotification = completed_goal_notification.try_into()?; - let ServerNotification::ThreadGoalUpdated(completed_goal_notification) = - completed_goal_notification - else { - anyhow::bail!("expected completed thread goal update notification"); - }; - let state_db = - StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".to_string()).await?; - let persisted_thread_id = ThreadId::from_string(&thread.id)?; - let first_goal = state_db - .thread_goals() - .get_thread_goal(persisted_thread_id) - .await? - .expect("first goal should be persisted"); - assert_eq!( - ( - completed_goal_notification.goal.status, - first_goal.objective.clone(), - first_goal.status, - ), - ( - ThreadGoalStatus::Complete, - "Improve benchmark coverage\n\nDocument the results".to_string(), - codex_state::ThreadGoalStatus::Complete, - ) - ); - - let replacement_turn_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Publish the benchmark report".to_string(), - text_elements: Vec::new(), - }], - goal: true, - ..Default::default() - }) - .await?; - let replacement_turn_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(replacement_turn_req)), - ) - .await??; - let TurnStartResponse { - turn: replacement_turn, - } = to_response(replacement_turn_resp)?; - let replacement_goal_notification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("thread/goal/updated"), - ) - .await??; - let replacement_goal_notification: ServerNotification = - replacement_goal_notification.try_into()?; - let ServerNotification::ThreadGoalUpdated(replacement_goal_notification) = - replacement_goal_notification - else { - anyhow::bail!("expected replacement thread goal update notification"); - }; - let replacement_goal = state_db - .thread_goals() - .get_thread_goal(persisted_thread_id) - .await? - .expect("replacement goal should be persisted"); - assert_eq!( - ( - replacement_goal_notification.thread_id, - replacement_goal_notification.turn_id, - replacement_goal_notification.goal.objective, - replacement_goal_notification.goal.status, - replacement_goal.goal_id != first_goal.goal_id, - replacement_goal.tokens_used, - replacement_goal.time_used_seconds, - ), - ( - thread.id.clone(), - Some(replacement_turn.id.clone()), - "Publish the benchmark report".to_string(), - ThreadGoalStatus::Active, - true, - 0, - 0, - ) - ); - - let mut replacement_lifecycles = Vec::new(); - for _ in 0..2 { - let started: TurnStartedNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/started"), - ) - .await?? - .params - .expect("replacement turn/started params"), - )?; - let completed: TurnCompletedNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await?? - .params - .expect("replacement turn/completed params"), - )?; - replacement_lifecycles.push((started.turn.id, completed.turn.id)); - } - assert_eq!( - ( - replacement_lifecycles[0].clone(), - replacement_lifecycles[1].0 != replacement_turn.id, - replacement_lifecycles[1].0 == replacement_lifecycles[1].1, - ), - ( - (replacement_turn.id.clone(), replacement_turn.id), - true, - true, - ) - ); - - Ok(()) -} - -#[tokio::test] -async fn turn_start_goal_validates_input_and_thread_persistence() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; - let codex_home = TempDir::new()?; - create_config_toml( - codex_home.path(), - &server.uri(), - "never", - &BTreeMap::from([(Feature::Goals, true)]), - )?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let persisted_thread_req = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let persisted_thread_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(persisted_thread_req)), - ) - .await??; - let ThreadStartResponse { - thread: persisted_thread, - .. - } = to_response(persisted_thread_resp)?; - let empty_goal_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: persisted_thread.id, - client_user_message_id: None, - input: Vec::new(), - goal: true, - ..Default::default() - }) - .await?; - let empty_goal_error: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(empty_goal_req)), - ) - .await??; - - let ephemeral_thread_req = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ephemeral: Some(true), - ..Default::default() - }) - .await?; - let ephemeral_thread_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(ephemeral_thread_req)), - ) - .await??; - let ThreadStartResponse { - thread: ephemeral_thread, - .. - } = to_response(ephemeral_thread_resp)?; - - let ephemeral_goal_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: ephemeral_thread.id.clone(), - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "Improve benchmark coverage".to_string(), - text_elements: Vec::new(), - }], - goal: true, - ..Default::default() - }) - .await?; - let ephemeral_goal_error: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(ephemeral_goal_req)), - ) - .await??; - assert_eq!( - ( - empty_goal_error.error.message, - ephemeral_goal_error.error.message, - ), - ( - "goal objective must not be empty".to_string(), - format!( - "ephemeral thread does not support goals: {}", - ephemeral_thread.id - ), - ) - ); - - Ok(()) -} - -#[tokio::test] -async fn concurrent_goal_starts_reject_the_loser_without_starting_a_turn() -> Result<()> { - let server = responses::start_mock_server().await; - responses::mount_response_once( - &server, - responses::sse_response(responses::sse(vec![ - responses::ev_assistant_message("msg-1", "Working."), - responses::ev_completed("resp-1"), - ])) - .set_delay(Duration::from_secs(1)), - ) - .await; - - let codex_home = TempDir::new()?; - create_config_toml( - codex_home.path(), - &server.uri(), - "never", - &BTreeMap::from([(Feature::Goals, true)]), - )?; - let mut mcp = TestAppServer::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 first_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![V2UserInput::Text { - text: "First goal".to_string(), - text_elements: Vec::new(), - }], - goal: true, - ..Default::default() - }) - .await?; - let second_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![V2UserInput::Text { - text: "Second goal".to_string(), - text_elements: Vec::new(), - }], - goal: true, - ..Default::default() - }) - .await?; - - let first_response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(first_req)), - ) - .await??; - let TurnStartResponse { turn } = to_response(first_response)?; - let second_error: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(second_req)), - ) - .await??; - let started: TurnStartedNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/started"), - ) - .await?? - .params - .expect("turn/started params"), - )?; - - assert_eq!( - ( - second_error.error.message, - started.thread_id, - started.turn.id, - ), - ( - "cannot start a goal while another turn is active".to_string(), - thread.id, - turn.id, - ) - ); - - Ok(()) -} - #[tokio::test] async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -2932,7 +2327,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { text: "first turn".to_string(), text_elements: Vec::new(), }], - goal: false, responsesapi_client_metadata: None, additional_context: None, cwd: Some(first_cwd.clone()), @@ -2977,7 +2371,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { text: "second turn".to_string(), text_elements: Vec::new(), }], - goal: false, responsesapi_client_metadata: None, additional_context: None, cwd: Some(second_cwd.clone()), diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index fc4d9eef62..b392bb53b9 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -5,7 +5,10 @@ use clap::Parser; use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; +use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; use codex_core_plugins::marketplace::marketplace_root_dir; use codex_core_plugins::marketplace_add::MarketplaceAddOutcome; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; @@ -15,9 +18,14 @@ use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; use serde::Serialize; +use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use crate::plugin_cmd::JsonMarketplaceSource; use crate::plugin_cmd::configured_marketplace_snapshot_issues; +use crate::plugin_cmd::configured_marketplace_sources; #[derive(Debug, Parser)] #[command(bin_name = "codex plugin marketplace")] @@ -240,7 +248,10 @@ async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceAr } let marketplaces = marketplace_listing.marketplaces; if args.json { - let output = JsonMarketplaceListOutput::from_marketplaces(marketplaces); + let marketplace_sources = + configured_marketplace_sources_by_root(config.codex_home.as_path(), &plugins_input); + let output = + JsonMarketplaceListOutput::from_marketplaces(marketplaces, &marketplace_sources); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } @@ -288,7 +299,10 @@ struct JsonMarketplaceListOutput { } impl JsonMarketplaceListOutput { - fn from_marketplaces(marketplaces: Vec) -> Self { + fn from_marketplaces( + marketplaces: Vec, + marketplace_sources: &HashMap, + ) -> Self { let mut seen_roots = HashSet::new(); let marketplaces = marketplaces .into_iter() @@ -298,6 +312,7 @@ impl JsonMarketplaceListOutput { return None; } Some(JsonMarketplaceListEntry { + marketplace_source: marketplace_sources.get(root.as_path()).cloned(), name: marketplace.name, root: root.display().to_string(), }) @@ -313,6 +328,38 @@ impl JsonMarketplaceListOutput { struct JsonMarketplaceListEntry { name: String, root: String, + #[serde(skip_serializing_if = "Option::is_none")] + marketplace_source: Option, +} + +fn configured_marketplace_sources_by_root( + codex_home: &Path, + plugins_input: &PluginsConfigInput, +) -> HashMap { + let marketplace_sources = configured_marketplace_sources(plugins_input); + let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { + return HashMap::new(); + }; + let Some(marketplaces) = user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return HashMap::new(); + }; + + let default_install_root = marketplace_install_root(codex_home); + marketplaces + .iter() + .filter_map(|(marketplace_name, marketplace)| { + let marketplace_source = marketplace_sources.get(marketplace_name)?; + let root = resolve_configured_marketplace_root( + marketplace_name, + marketplace, + &default_install_root, + )?; + Some((root, marketplace_source.clone())) + }) + .collect() } async fn run_upgrade( diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 7def7283c0..94c388066d 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -437,12 +437,12 @@ impl JsonPluginSource { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct JsonMarketplaceSource { +pub(crate) struct JsonMarketplaceSource { source_type: String, source: String, } -fn configured_marketplace_sources( +pub(crate) fn configured_marketplace_sources( plugins_input: &PluginsConfigInput, ) -> HashMap { let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index cda79cf9a1..c8bd7b5429 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -341,6 +341,7 @@ async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { #[tokio::test] async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; + let source_path = source.path().display().to_string(); let assert = codex_command(codex_home.path())? .args(["plugin", "marketplace", "list", "--json"]) @@ -355,7 +356,112 @@ async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { "marketplaces": [ { "name": "debug", - "root": source.path().display().to_string(), + "root": source_path, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_json_includes_configured_git_marketplace_source() -> Result<()> { + let codex_home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let normalized_root = canonicalize_existing_preserving_symlinks(&marketplace_root)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaces": [ + { + "name": "debug", + "root": normalized_root.display().to_string(), + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_json_keys_configured_source_by_root() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let normalized_root = canonicalize_existing_preserving_symlinks(&marketplace_root)?; + + let assert = codex_command(codex_home.path())? + .env("HOME", home.path()) + .args(["plugin", "marketplace", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaces": [ + { + "name": "debug", + "root": home.path().display().to_string(), + }, + { + "name": "debug", + "root": normalized_root.display().to_string(), + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, }, ], }) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index dd7b9f400c..02d231c962 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -802,10 +802,16 @@ impl ModelClient { service_tier, prompt_cache_key, text, - client_metadata: Some(HashMap::from([( - X_CODEX_INSTALLATION_ID_HEADER.to_string(), - self.state.installation_id.clone(), - )])), + client_metadata: Some(HashMap::from([ + ( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.state.installation_id.clone(), + ), + ( + X_CODEX_WINDOW_ID_HEADER.to_string(), + self.current_window_id(), + ), + ])), }; Ok(request) } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b3e579b49e..5a1473b057 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -225,7 +225,6 @@ pub(crate) async fn run_codex_thread_one_shot( id: "shutdown".to_string(), op: Op::Shutdown {}, client_user_message_id: None, - initial_goal: None, trace: None, }) .await; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 01ecd80262..7dabb09d33 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -131,7 +131,6 @@ async fn forward_ops_preserves_submission_trace_context() { id: "sub-1".to_string(), op: Op::Interrupt, client_user_message_id: None, - initial_goal: None, trace: Some(codex_protocol::protocol::W3cTraceContext { traceparent: Some( "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ab5eb07f8a..1697f768e2 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -22,7 +22,6 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; -use codex_protocol::protocol::InitialGoal; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; @@ -234,7 +233,6 @@ impl CodexThread { op: Op, trace: Option, client_user_message_id: Option, - initial_goal: Option, ) -> CodexResult { self.codex .session @@ -243,12 +241,7 @@ impl CodexThread { .ensure_execution_capacity_for_op(self.session_configured.thread_id, &op) .await?; self.codex - .submit_user_input_with_client_user_message_id( - op, - trace, - client_user_message_id, - initial_goal, - ) + .submit_user_input_with_client_user_message_id(op, trace, client_user_message_id) .await } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 18b5838292..e2573d6d6d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -150,7 +150,7 @@ async fn run_compact_task_inner( sess.as_ref(), CompactionStatus::Interrupted, Some(error), - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; return Err(CodexErr::TurnAborted); @@ -174,7 +174,7 @@ async fn run_compact_task_inner( sess.as_ref(), status, error, - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; return Err(CodexErr::TurnAborted); @@ -185,7 +185,7 @@ async fn run_compact_task_inner( sess.as_ref(), status, error, - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; result.map(|_| ()) @@ -335,6 +335,13 @@ pub(crate) struct CompactionAnalyticsAttempt { start_instant: Instant, } +#[derive(Clone, Copy, Default)] +pub(crate) struct CompactionAnalyticsDetails { + pub(crate) active_context_tokens_before: Option, + pub(crate) retained_image_count: Option, + pub(crate) compaction_summary_tokens: Option, +} + impl CompactionAnalyticsAttempt { pub(crate) async fn begin( sess: &Session, @@ -363,8 +370,13 @@ impl CompactionAnalyticsAttempt { sess: &Session, status: CompactionStatus, error: Option, - active_context_tokens_before: Option, + details: CompactionAnalyticsDetails, ) { + let CompactionAnalyticsDetails { + active_context_tokens_before, + retained_image_count, + compaction_summary_tokens, + } = details; let active_context_tokens_before = active_context_tokens_before.unwrap_or(self.active_context_tokens_before); let active_context_tokens_after = sess.get_total_token_usage().await; @@ -382,6 +394,8 @@ impl CompactionAnalyticsAttempt { error, active_context_tokens_before, active_context_tokens_after, + retained_image_count, + compaction_summary_tokens, started_at: self.started_at, completed_at: now_unix_seconds(), duration_ms: Some( diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index c7730ddfb6..5fca191b84 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::Prompt; use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; +use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; @@ -100,7 +101,10 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompact, phase, ); - let mut active_context_tokens_before = sess.get_total_token_usage().await; + let mut analytics_details = CompactionAnalyticsDetails { + active_context_tokens_before: Some(sess.get_total_token_usage().await), + ..Default::default() + }; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -120,7 +124,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), - Some(active_context_tokens_before), + analytics_details, ) .await; return Err(CodexErr::TurnAborted); @@ -131,7 +135,7 @@ async fn run_remote_compact_task_inner( turn_context, initial_context_injection, compaction_metadata, - &mut active_context_tokens_before, + &mut analytics_details, ) .await; let status = compaction_status_from_result(&result); @@ -140,23 +144,13 @@ async fn run_remote_compact_task_inner( let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { attempt - .track( - sess.as_ref(), - status, - error, - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error, analytics_details) .await; return Err(CodexErr::TurnAborted); } } attempt - .track( - sess.as_ref(), - status, - error.clone(), - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error.clone(), analytics_details) .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); @@ -174,7 +168,7 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, - active_context_tokens_before: &mut i64, + analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, @@ -208,8 +202,12 @@ async fn run_remote_compact_task_inner_impl( .get_total_token_usage_breakdown() .await .estimated_tokens_of_items_added_since_last_successful_api_response; - *active_context_tokens_before = (*active_context_tokens_before) - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); } // This is the history selected for remote compaction, after any output rewriting required to // fit the compact endpoint. The checkpoint below records it separately from the next sampling diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 5f585862dd..1d4eb8ad5a 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -5,6 +5,7 @@ use crate::ResponseStream; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; use crate::compact::CompactionAnalyticsAttempt; +use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact_remote::build_compact_request_log_data; @@ -112,7 +113,10 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompactionV2, phase, ); - let mut active_context_tokens_before = sess.get_total_token_usage().await; + let mut analytics_details = CompactionAnalyticsDetails { + active_context_tokens_before: Some(sess.get_total_token_usage().await), + ..Default::default() + }; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -132,7 +136,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), - Some(active_context_tokens_before), + analytics_details, ) .await; return Err(CodexErr::TurnAborted); @@ -144,7 +148,7 @@ async fn run_remote_compact_task_inner( client_session, initial_context_injection, compaction_metadata, - &mut active_context_tokens_before, + &mut analytics_details, ) .await; let status = compaction_status_from_result(&result); @@ -153,23 +157,13 @@ async fn run_remote_compact_task_inner( let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { attempt - .track( - sess.as_ref(), - status, - error, - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error, analytics_details) .await; return Err(CodexErr::TurnAborted); } } attempt - .track( - sess.as_ref(), - status, - error.clone(), - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error.clone(), analytics_details) .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); @@ -188,7 +182,7 @@ async fn run_remote_compact_task_inner_impl( client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, - active_context_tokens_before: &mut i64, + analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( @@ -221,8 +215,12 @@ async fn run_remote_compact_task_inner_impl( .get_total_token_usage_breakdown() .await .estimated_tokens_of_items_added_since_last_successful_api_response; - *active_context_tokens_before = (*active_context_tokens_before) - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); } let trace_input_history = history.raw_items().to_vec(); @@ -283,9 +281,12 @@ async fn run_remote_compact_task_inner_impl( token_usage, } = compaction_output_result?; if let Some(token_usage) = token_usage { - *active_context_tokens_before = token_usage.input_tokens; + analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); + analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens); } - let compacted_history = build_v2_compacted_history(&prompt_input, compaction_output); + let (compacted_history, retained_images) = + build_v2_compacted_history(&prompt_input, compaction_output); + analytics_details.retained_image_count = Some(retained_images); let new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -447,7 +448,7 @@ async fn collect_compaction_output( fn build_v2_compacted_history( prompt_input: &[ResponseItem], compaction_output: ResponseItem, -) -> Vec { +) -> (Vec, usize) { let retained = prompt_input .iter() .filter(|item| is_retained_for_remote_compaction_v2(item)) @@ -456,8 +457,12 @@ fn build_v2_compacted_history( .collect::>(); let mut retained = truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET); + let retained_image_count = retained + .iter() + .map(retained_input_image_count) + .sum::(); retained.push(compaction_output); - retained + (retained, retained_image_count) } fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { @@ -468,6 +473,17 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { matches!(role.as_str(), "user" | "developer" | "system") } +fn retained_input_image_count(item: &ResponseItem) -> usize { + let ResponseItem::Message { content, .. } = item else { + return 0; + }; + + content + .iter() + .filter(|item| matches!(item, ContentItem::InputImage { .. })) + .count() +} + fn truncate_retained_messages_for_remote_compaction( items: Vec, max_tokens: usize, @@ -617,7 +633,7 @@ mod tests { encrypted_content: "new".to_string(), }; - let history = build_v2_compacted_history(&input, output.clone()); + let (history, _) = build_v2_compacted_history(&input, output.clone()); assert_eq!( history, @@ -644,11 +660,40 @@ mod tests { encrypted_content: "new".to_string(), }; - let history = build_v2_compacted_history(&input, output.clone()); + let (history, _) = build_v2_compacted_history(&input, output.clone()); assert_eq!(history, vec![old, new, output]); } + #[test] + fn build_v2_compacted_history_counts_retained_input_images() { + let input = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "user".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,def".to_string(), + detail: None, + }, + ], + phase: None, + }]; + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), + }; + + let (_, retained_image_count) = build_v2_compacted_history(&input, output); + + assert_eq!(retained_image_count, 2); + } + #[test] fn retained_history_truncation_keeps_newest_messages_first() { let middle = message("user", "middle1234", /*phase*/ None); diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index bca44d1f2a..7730a30ad7 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -4,7 +4,6 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s use crate::realtime_conversation::handle_text as handle_realtime_conversation_text; use async_channel::Receiver; use codex_otel::set_parent_from_w3c_trace_context; -use codex_protocol::error::CodexErr; use codex_protocol::protocol::Submission; use tracing::Instrument; use tracing::debug_span; @@ -15,7 +14,6 @@ use crate::session::TurnInput; use crate::session::session::Session; use crate::session::session::SessionSettingsUpdate; -use super::initial_goal::InitialGoalStartError; use crate::config::Config; use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET; use crate::realtime_context::truncate_realtime_text_to_token_budget; @@ -23,8 +21,6 @@ use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX; use crate::realtime_conversation::prefix_realtime_v2_text; use crate::review_prompts::resolve_review_request; use crate::session::spawn_review_thread; -use crate::state::ActiveTurn; -use crate::state::TurnState; use crate::tasks::CompactTask; use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; @@ -38,7 +34,6 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; -use codex_protocol::protocol::InitialGoal; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; @@ -66,7 +61,6 @@ use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use serde_json::Value; use std::sync::Arc; -use tokio::sync::Mutex; use tracing::debug; use tracing::info; use tracing::warn; @@ -96,7 +90,6 @@ pub async fn user_input_or_turn( sub_id: String, op: Op, client_user_message_id: Option, - initial_goal: Option, ) { user_input_or_turn_inner( sess, @@ -104,28 +97,10 @@ pub async fn user_input_or_turn( op, /*mirror_user_text_to_realtime*/ Some(()), client_user_message_id, - initial_goal, ) .await; } -async fn clear_reserved_goal_turn(sess: &Session, turn_state: &Arc>) { - let cleared = { - let mut active_turn = sess.active_turn.lock().await; - if active_turn.as_ref().is_some_and(|active_turn| { - active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, turn_state) - }) { - *active_turn = None; - true - } else { - false - } - }; - if cleared { - sess.emit_thread_idle_lifecycle_if_idle().await; - } -} - pub async fn update_thread_settings( sess: &Arc, sub_id: String, @@ -222,7 +197,6 @@ pub(super) async fn user_input_or_turn_inner( op: Op, mirror_user_text_to_realtime: Option<()>, client_user_message_id: Option, - initial_goal: Option, ) { let Op::UserInput { items, @@ -244,67 +218,9 @@ pub(super) async fn user_input_or_turn_inner( updates.final_output_json_schema = Some(final_output_json_schema); updates.environments = environments; - let mut reserved_goal_turn = None; - if initial_goal.is_some() { - let turn_state = { - let mut active_turn = sess.active_turn.lock().await; - if active_turn.is_some() { - None - } else { - let active_turn = active_turn.get_or_insert_with(ActiveTurn::default); - Some(Arc::clone(&active_turn.turn_state)) - } - }; - let Some(turn_state) = turn_state else { - sess.complete_initial_goal_start( - &sub_id, - Err(InitialGoalStartError::InvalidRequest( - "cannot start a goal while another turn is active".to_string(), - )), - ); - return; - }; - reserved_goal_turn = Some(turn_state); - } - - let current_context = if let Some(initial_goal) = initial_goal { - let prepared_turn = match sess.prepare_turn(updates).await { - Ok(prepared_turn) => prepared_turn, - Err(err) => { - if let Some(turn_state) = reserved_goal_turn.as_ref() { - clear_reserved_goal_turn(sess, turn_state).await; - } - let err = match err { - CodexErr::InvalidRequest(message) => { - InitialGoalStartError::InvalidRequest(message) - } - err => InitialGoalStartError::Internal(err.to_string()), - }; - sess.complete_initial_goal_start(&sub_id, Err(err)); - return; - } - }; - match sess - .prepare_initial_goal(&sub_id, &prepared_turn, &initial_goal) - .await - { - Ok(()) => {} - Err(err) => { - if let Some(turn_state) = reserved_goal_turn.as_ref() { - clear_reserved_goal_turn(sess, turn_state).await; - } - sess.complete_initial_goal_start(&sub_id, Err(err)); - return; - } - } - - sess.commit_prepared_turn(sub_id.clone(), prepared_turn) - .await - } else { - match sess.new_turn_with_sub_id(sub_id.clone(), updates).await { - Ok(current_context) => current_context, - Err(_) => return, - } + let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { + // new_turn_with_sub_id already emits the error event. + return; }; if emit_thread_settings_applied { sess.send_event_raw(Event { @@ -325,18 +241,6 @@ pub(super) async fn user_input_or_turn_inner( ) .await { - Ok(_) if reserved_goal_turn.is_some() => { - if let Some(turn_state) = reserved_goal_turn.as_ref() { - clear_reserved_goal_turn(sess, turn_state).await; - } - sess.complete_initial_goal_start( - &sub_id, - Err(InitialGoalStartError::Internal( - "goal turn reservation was replaced before startup".to_string(), - )), - ); - return; - } Ok(_) => { current_context.session_telemetry.user_prompt(&items); Some(items) @@ -369,35 +273,15 @@ pub(super) async fn user_input_or_turn_inner( client_id: client_user_message_id, }); } - if reserved_goal_turn.is_some() { - sess.start_task( - Arc::clone(¤t_context), - task_input, - crate::tasks::RegularTask::new(), - ) - .await; - sess.complete_initial_goal_start(&sub_id, Ok(())); - } else { - sess.spawn_task( - Arc::clone(¤t_context), - task_input, - crate::tasks::RegularTask::new(), - ) - .await; - } + sess.spawn_task( + Arc::clone(¤t_context), + task_input, + crate::tasks::RegularTask::new(), + ) + .await; Some(accepted_items) } Err(err) => { - if let Some(turn_state) = reserved_goal_turn.as_ref() { - clear_reserved_goal_turn(sess, turn_state).await; - sess.complete_initial_goal_start( - &sub_id, - Err(InitialGoalStartError::Internal( - err.to_error_event().message, - )), - ); - return; - } sess.send_event_raw(Event { id: sub_id, msg: EventMsg::Error(err.to_error_event()), @@ -903,14 +787,8 @@ pub(super) async fn submission_loop( false } Op::UserInput { .. } => { - user_input_or_turn( - &sess, - sub.id.clone(), - sub.op, - sub.client_user_message_id, - sub.initial_goal, - ) - .await; + user_input_or_turn(&sess, sub.id.clone(), sub.op, sub.client_user_message_id) + .await; false } Op::ThreadSettings { thread_settings } => { diff --git a/codex-rs/core/src/session/initial_goal.rs b/codex-rs/core/src/session/initial_goal.rs deleted file mode 100644 index b9636cb184..0000000000 --- a/codex-rs/core/src/session/initial_goal.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; -use std::sync::PoisonError; - -use codex_extension_api::InitialGoalError; -use codex_extension_api::InitialGoalInput; -use codex_protocol::protocol::InitialGoal; -use tokio::sync::oneshot; - -use super::session::Session; -use super::turn_context::PreparedTurn; - -pub(crate) enum InitialGoalStartError { - InvalidRequest(String), - Internal(String), -} - -#[derive(Default)] -pub(crate) struct InitialGoalStartAcks { - senders: Mutex>>>, -} - -impl InitialGoalStartAcks { - pub(crate) fn register( - &self, - turn_id: String, - ) -> oneshot::Receiver> { - let (sender, receiver) = oneshot::channel(); - self.senders - .lock() - .unwrap_or_else(PoisonError::into_inner) - .insert(turn_id, sender); - receiver - } - - pub(crate) fn complete(&self, turn_id: &str, result: Result<(), InitialGoalStartError>) { - if let Some(sender) = self - .senders - .lock() - .unwrap_or_else(PoisonError::into_inner) - .remove(turn_id) - { - let _ = sender.send(result); - } - } - - pub(crate) fn cancel(&self, turn_id: &str) { - self.senders - .lock() - .unwrap_or_else(PoisonError::into_inner) - .remove(turn_id); - } -} - -impl Session { - pub(crate) async fn prepare_initial_goal( - &self, - turn_id: &str, - prepared_turn: &PreparedTurn, - goal: &InitialGoal, - ) -> Result<(), InitialGoalStartError> { - let contributor = self - .services - .extensions - .initial_goal_contributor() - .ok_or_else(|| { - InitialGoalStartError::Internal( - "goal extension is unavailable for this thread".to_string(), - ) - })?; - contributor - .replace_for_turn(InitialGoalInput { - turn_id, - goal, - collaboration_mode: &prepared_turn.session_configuration.collaboration_mode, - session_store: &self.services.session_extension_data, - thread_store: &self.services.thread_extension_data, - }) - .await - .map_err(|err| match err { - InitialGoalError::InvalidRequest(message) => { - InitialGoalStartError::InvalidRequest(message) - } - InitialGoalError::Internal(message) => InitialGoalStartError::Internal(message), - }) - } - - pub(crate) fn register_initial_goal_start_ack( - &self, - turn_id: String, - ) -> oneshot::Receiver> { - self.initial_goal_start_acks.register(turn_id) - } - - pub(crate) fn complete_initial_goal_start( - &self, - turn_id: &str, - result: Result<(), InitialGoalStartError>, - ) { - self.initial_goal_start_acks.complete(turn_id, result); - } - - pub(crate) fn cancel_initial_goal_start(&self, turn_id: &str) { - self.initial_goal_start_acks.cancel(turn_id); - } -} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9c309eb63d..37d55256f5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -9,7 +9,6 @@ use std::sync::atomic::AtomicU64; use std::time::SystemTime; use std::time::UNIX_EPOCH; -use self::initial_goal::InitialGoalStartError; use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; @@ -197,7 +196,6 @@ use codex_protocol::exec_output::StreamOutput; mod config_lock; mod handlers; -mod initial_goal; mod inject; mod input_queue; mod mcp; @@ -339,7 +337,6 @@ use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::InitialGoal; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::ModelRerouteEvent; @@ -383,17 +380,6 @@ pub struct Codex { pub(crate) session_loop_termination: SessionLoopTermination, } -struct InitialGoalStartAckGuard { - session: Arc, - turn_id: String, -} - -impl Drop for InitialGoalStartAckGuard { - fn drop(&mut self) { - self.session.cancel_initial_goal_start(&self.turn_id); - } -} - pub(crate) type SessionLoopTermination = Shared>; /// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`] and @@ -701,7 +687,6 @@ impl Codex { id: id.clone(), op, client_user_message_id: None, - initial_goal: None, trace, }; self.submit_with_id(sub).await?; @@ -713,43 +698,16 @@ impl Codex { op: Op, trace: Option, client_user_message_id: Option, - initial_goal: Option, ) -> CodexResult { debug_assert!(matches!(op, Op::UserInput { .. })); let id = Uuid::now_v7().to_string(); - let mut _goal_start_ack_guard = None; - let goal_start_ack = if initial_goal.is_some() { - let receiver = self.session.register_initial_goal_start_ack(id.clone()); - _goal_start_ack_guard = Some(InitialGoalStartAckGuard { - session: Arc::clone(&self.session), - turn_id: id.clone(), - }); - Some(receiver) - } else { - None - }; let sub = Submission { id: id.clone(), op, client_user_message_id, - initial_goal, trace, }; self.submit_with_id(sub).await?; - if let Some(goal_start_ack) = goal_start_ack { - let goal_start_result = tokio::select! { - result = goal_start_ack => { - result.map_err(|_| CodexErr::InternalAgentDied)? - } - () = self.session_loop_termination.clone() => { - return Err(CodexErr::InternalAgentDied); - } - }; - goal_start_result.map_err(|err| match err { - InitialGoalStartError::InvalidRequest(message) => CodexErr::InvalidRequest(message), - InitialGoalStartError::Internal(message) => CodexErr::Fatal(message), - })?; - } Ok(id) } @@ -1168,7 +1126,6 @@ impl Session { }, /*mirror_user_text_to_realtime*/ None, /*client_user_message_id*/ None, - /*initial_goal*/ None, ) .await; } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7c2661615d..92f9737310 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,4 +1,3 @@ -use super::initial_goal::InitialGoalStartAcks; use super::input_queue::InputQueue; use super::*; use crate::agents_md::LoadedAgentsMd; @@ -37,7 +36,6 @@ pub(crate) struct Session { pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, pub(crate) input_queue: InputQueue, - pub(crate) initial_goal_start_acks: InitialGoalStartAcks, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, pub(super) next_internal_sub_id: AtomicU64, @@ -1064,7 +1062,6 @@ impl Session { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: InputQueue::new(), - initial_goal_start_acks: InitialGoalStartAcks::default(), guardian_review_session: GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8fda33a046..4d914ff985 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4899,7 +4899,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - initial_goal_start_acks: super::initial_goal::InitialGoalStartAcks::default(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), @@ -5826,7 +5825,6 @@ async fn submit_with_id_captures_current_span_trace_context() { id: "sub-1".into(), op: Op::Interrupt, client_user_message_id: None, - initial_goal: None, trace: None, }) .await @@ -5899,7 +5897,6 @@ fn submission_dispatch_span_prefers_submission_trace_context() { id: "sub-1".into(), op: Op::Interrupt, client_user_message_id: None, - initial_goal: None, trace: Some(submission_trace), }) }); @@ -5927,7 +5924,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { }, }), client_user_message_id: None, - initial_goal: None, trace: None, }); @@ -5996,7 +5992,6 @@ async fn user_turn_updates_approvals_reviewer() { }, }, /*client_user_message_id*/ None, - /*initial_goal*/ None, ) .await; @@ -6291,7 +6286,6 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { id: "sub-1".into(), op: Op::Interrupt, client_user_message_id: None, - initial_goal: None, trace: Some(submission_trace.clone()), }); let dispatch_span_id = dispatch_span.context().span().span_context().span_id(); @@ -6982,7 +6976,6 @@ where conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - initial_goal_start_acks: super::initial_goal::InitialGoalStartAcks::default(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index d4ee339c2a..5f52912d59 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -111,18 +111,6 @@ enum TurnMultiAgentRuntime { Preview, } -pub(crate) struct PreparedTurn { - pub(super) session_configuration: SessionConfiguration, - turn_environments: ResolvedTurnEnvironments, - permission_profile_changed: bool, - previous_cwd: AbsolutePathBuf, - codex_home: AbsolutePathBuf, - session_source: SessionSource, - previous_config: Option, - new_config: Option, - final_output_json_schema: Option>, -} - impl TurnContext { pub(crate) fn permission_profile(&self) -> PermissionProfile { self.permission_profile.clone() @@ -594,8 +582,63 @@ impl Session { sub_id: String, updates: SessionSettingsUpdate, ) -> CodexResult> { - let prepared_turn = match self.prepare_turn(updates).await { - Ok(prepared_turn) => prepared_turn, + let notify_config_contributors = !self.services.extensions.config_contributors().is_empty(); + let update_result: CodexResult<_> = { + let mut state = self.state.lock().await; + match state.session_configuration.clone().apply(&updates) { + Ok(next) => { + let mut effective_environments = updates + .environments + .clone() + .unwrap_or_else(|| next.environments.clone()); + if updates.environments.is_none() { + Self::overlay_runtime_cwd_on_primary_environment( + &mut effective_environments, + &next.cwd, + ); + } + let turn_environments = + self.resolve_turn_environments(&effective_environments)?; + let previous_cwd = state.session_configuration.cwd.clone(); + let previous_permission_profile = + state.session_configuration.permission_profile(); + let next_permission_profile = next.permission_profile(); + let permission_profile_changed = + previous_permission_profile != next_permission_profile; + let codex_home = next.codex_home.clone(); + let session_source = next.session_source.clone(); + let previous_config = notify_config_contributors.then(|| { + Self::build_effective_session_config(&state.session_configuration) + }); + let new_config = notify_config_contributors + .then(|| Self::build_effective_session_config(&next)); + state.session_configuration = next.clone(); + Ok(( + next, + turn_environments, + permission_profile_changed, + previous_cwd, + codex_home, + session_source, + previous_config, + new_config, + )) + } + Err(err) => Err(CodexErr::InvalidRequest(err.to_string())), + } + }; + + let ( + session_configuration, + turn_environments, + permission_profile_changed, + previous_cwd, + codex_home, + session_source, + previous_config, + new_config, + ) = match update_result { + Ok(update) => update, Err(err) => { let message = err.to_string(); self.send_event_raw(Event { @@ -609,74 +652,6 @@ impl Session { return Err(CodexErr::InvalidRequest(message)); } }; - Ok(self.commit_prepared_turn(sub_id, prepared_turn).await) - } - - pub(crate) async fn prepare_turn( - &self, - updates: SessionSettingsUpdate, - ) -> CodexResult { - let notify_config_contributors = !self.services.extensions.config_contributors().is_empty(); - let state = self.state.lock().await; - let session_configuration = state - .session_configuration - .clone() - .apply(&updates) - .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; - let mut effective_environments = updates - .environments - .clone() - .unwrap_or_else(|| session_configuration.environments.clone()); - if updates.environments.is_none() { - Self::overlay_runtime_cwd_on_primary_environment( - &mut effective_environments, - &session_configuration.cwd, - ); - } - let turn_environments = self.resolve_turn_environments(&effective_environments)?; - let previous_cwd = state.session_configuration.cwd.clone(); - let previous_permission_profile = state.session_configuration.permission_profile(); - let next_permission_profile = session_configuration.permission_profile(); - let permission_profile_changed = previous_permission_profile != next_permission_profile; - let codex_home = session_configuration.codex_home.clone(); - let session_source = session_configuration.session_source.clone(); - let previous_config = notify_config_contributors - .then(|| Self::build_effective_session_config(&state.session_configuration)); - let new_config = notify_config_contributors - .then(|| Self::build_effective_session_config(&session_configuration)); - Ok(PreparedTurn { - session_configuration, - turn_environments, - permission_profile_changed, - previous_cwd, - codex_home, - session_source, - previous_config, - new_config, - final_output_json_schema: updates.final_output_json_schema, - }) - } - - pub(crate) async fn commit_prepared_turn( - &self, - sub_id: String, - prepared_turn: PreparedTurn, - ) -> Arc { - let PreparedTurn { - session_configuration, - turn_environments, - permission_profile_changed, - previous_cwd, - codex_home, - session_source, - previous_config, - new_config, - final_output_json_schema, - } = prepared_turn; - { - let mut state = self.state.lock().await; - state.session_configuration = session_configuration.clone(); - } self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); self.maybe_refresh_shell_snapshot_for_cwd( @@ -691,13 +666,14 @@ impl Session { .await; } - self.new_turn_from_configuration( - sub_id, - session_configuration, - final_output_json_schema, - turn_environments, - ) - .await + Ok(self + .new_turn_from_configuration( + sub_id, + session_configuration, + updates.final_output_json_schema, + turn_environments, + ) + .await) } fn resolve_turn_environments( diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 1cc5e67eb5..20e08e9f7d 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -28,6 +28,7 @@ use codex_extension_api::ToolCallOutcome; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; +use codex_rollout::state_db; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; @@ -696,6 +697,16 @@ async fn handle_any_tool( let call_id = invocation.call_id.clone(); let payload = invocation.payload.clone(); let output = tool.handle(invocation.clone()).await?; + if output.contains_external_context() + && invocation.turn.config.memories.disable_on_external_context + { + state_db::mark_thread_memory_mode_polluted( + invocation.session.services.state_db.as_deref(), + invocation.session.thread_id, + "tool_output", + ) + .await; + } let post_tool_use_payload = CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref()); Ok(AnyToolResult { diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 75649fa4bc..1ba93aec10 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -158,6 +158,10 @@ async fn responses_stream_includes_subagent_header_on_review() { request.body_json()["client_metadata"]["x-codex-installation-id"].as_str(), Some(TEST_INSTALLATION_ID) ); + assert_eq!( + request.body_json()["client_metadata"]["x-codex-window-id"].as_str(), + Some(expected_window_id.as_str()) + ); assert_eq!(request.header("x-codex-sandbox"), None); } diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 2200c7b778..12fd570c56 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -883,6 +883,10 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( compact_metadata["window_id"].as_str(), compact_request.header("x-codex-window-id").as_deref() ); + assert_eq!( + compact_request.body_json()["client_metadata"]["x-codex-window-id"].as_str(), + compact_metadata["window_id"].as_str() + ); assert_eq!( compact_metadata["compaction"], json!({ diff --git a/codex-rs/core/tests/suite/initial_goal.rs b/codex-rs/core/tests/suite/initial_goal.rs deleted file mode 100644 index b331f63527..0000000000 --- a/codex-rs/core/tests/suite/initial_goal.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::PoisonError; - -use anyhow::Result; -use codex_core::config::Config; -use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::InitialGoalContributor; -use codex_extension_api::InitialGoalError; -use codex_extension_api::InitialGoalInput; -use codex_protocol::error::CodexErr; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::InitialGoal; -use codex_protocol::protocol::Op; -use codex_protocol::user_input::UserInput; -use core_test_support::responses; -use core_test_support::responses::ev_assistant_message; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_response_created; -use core_test_support::streaming_sse::StreamingSseChunk; -use core_test_support::streaming_sse::start_streaming_sse_server; -use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_match; -use pretty_assertions::assert_eq; -use serde_json::Value; -use tokio::sync::oneshot; - -#[derive(Clone, Debug, PartialEq, Eq)] -struct RecordedInitialGoal { - turn_id: String, - objective: String, -} - -#[derive(Default)] -struct RecordingInitialGoalContributor { - calls: Mutex>, -} - -impl RecordingInitialGoalContributor { - fn calls(&self) -> Vec { - self.calls - .lock() - .unwrap_or_else(PoisonError::into_inner) - .clone() - } -} - -impl InitialGoalContributor for RecordingInitialGoalContributor { - fn replace_for_turn<'a>( - &'a self, - input: InitialGoalInput<'a>, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.calls - .lock() - .unwrap_or_else(PoisonError::into_inner) - .push(RecordedInitialGoal { - turn_id: input.turn_id.to_string(), - objective: input.goal.objective.clone(), - }); - Ok(()) - }) - } -} - -fn user_input(text: &str) -> Op { - Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - environments: None, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn initial_goal_starts_one_turn_and_rejects_a_concurrent_goal() -> Result<()> { - let (completion_gate_tx, completion_gate_rx) = oneshot::channel(); - let (server, _) = start_streaming_sse_server(vec![vec![ - StreamingSseChunk { - gate: None, - body: responses::sse(vec![ev_response_created("response-1")]), - }, - StreamingSseChunk { - gate: Some(completion_gate_rx), - body: responses::sse(vec![ - ev_assistant_message("message-1", "Initial pass complete."), - ev_completed("response-1"), - ]), - }, - ]]) - .await; - - let contributor = Arc::new(RecordingInitialGoalContributor::default()); - let mut extension_builder = ExtensionRegistryBuilder::::new(); - extension_builder.initial_goal_contributor(contributor.clone()); - let mut builder = test_codex() - .with_model("gpt-5.4") - .with_extensions(Arc::new(extension_builder.build())); - let test = builder.build_with_streaming_server(&server).await?; - - let first_turn_id = test - .codex - .submit_user_input_with_client_user_message_id( - user_input("Improve benchmark coverage"), - /*trace*/ None, - /*client_user_message_id*/ None, - Some(InitialGoal { - objective: "Improve benchmark coverage".to_string(), - }), - ) - .await?; - let started_turn_id = wait_for_event_match(&test.codex, |event| match event { - EventMsg::TurnStarted(event) => Some(event.turn_id.clone()), - _ => None, - }) - .await; - server.wait_for_request_count(/*count*/ 1).await; - - let second_error = match test - .codex - .submit_user_input_with_client_user_message_id( - user_input("Replace the active goal"), - /*trace*/ None, - /*client_user_message_id*/ None, - Some(InitialGoal { - objective: "Replace the active goal".to_string(), - }), - ) - .await - { - Err(CodexErr::InvalidRequest(message)) => message, - Err(err) => anyhow::bail!("expected invalid request, got {err}"), - Ok(turn_id) => anyhow::bail!("concurrent goal unexpectedly started turn {turn_id}"), - }; - - completion_gate_tx - .send(()) - .map_err(|()| anyhow::anyhow!("response completion gate closed"))?; - let completed_turn_id = wait_for_event_match(&test.codex, |event| match event { - EventMsg::TurnComplete(event) => Some(event.turn_id.clone()), - _ => None, - }) - .await; - let requests = server.requests().await; - let request_body: Value = serde_json::from_slice( - requests - .first() - .ok_or_else(|| anyhow::anyhow!("expected initial model request"))?, - )?; - let original_user_texts = request_body - .get("input") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter(|item| item.get("type").and_then(Value::as_str) == Some("message")) - .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter_map(|content| content.get("text").and_then(Value::as_str)) - .filter(|text| !text.starts_with("")) - .map(str::to_string) - .collect::>(); - - assert_eq!( - ( - contributor.calls(), - started_turn_id, - completed_turn_id, - second_error, - requests.len(), - original_user_texts, - ), - ( - vec![RecordedInitialGoal { - turn_id: first_turn_id.clone(), - objective: "Improve benchmark coverage".to_string(), - }], - first_turn_id.clone(), - first_turn_id, - "cannot start a goal while another turn is active".to_string(), - 1, - vec!["Improve benchmark coverage".to_string()], - ) - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 52d5412336..234fb8a2f0 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -60,7 +60,6 @@ mod hooks; #[cfg(not(target_os = "windows"))] mod hooks_mcp; mod image_rollout; -mod initial_goal; mod items; mod json_result; mod live_cli; 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 index 750250f86a..89796cb918 100644 --- 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 @@ -7,7 +7,8 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s --- Last Normal /responses Request +++ Remote /responses/compact Request - "client_metadata": { -- "x-codex-installation-id": "" +- "x-codex-installation-id": "", +- "x-codex-window-id": ":0" - }, - "include": [ - "reasoning.encrypted_content" 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 index e28ddd30d1..ab6c9d1b37 100644 --- 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 @@ -7,7 +7,8 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses --- Last Normal /responses Request +++ Remote /responses/compact Request - "client_metadata": { -- "x-codex-installation-id": "" +- "x-codex-installation-id": "", +- "x-codex-window-id": ":0" - }, - "include": [ - "reasoning.encrypted_content" diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 9f8c940a0f..eb9f11a86d 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -1,8 +1,12 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::config::Config; +use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; +use codex_login::CodexAuth; use codex_protocol::ThreadId; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -15,6 +19,7 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::UserInput; +use codex_web_search_extension::install as install_web_search_extension; use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -34,9 +39,14 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; use std::fs; +use std::sync::Arc; use tokio::time::Duration; use tracing_subscriber::prelude::*; use uuid::Uuid; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn new_thread_is_recorded_in_state_db() -> Result<()> { @@ -384,6 +394,84 @@ async fn web_search_marks_thread_memory_mode_polluted_when_configured() -> Resul Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn standalone_web_search_marks_thread_memory_mode_polluted_when_configured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + Mock::given(method("POST")) + .and(path("/v1/alpha/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output": "Search result", + }))) + .expect(1) + .mount(&server) + .await; + mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + "web-run-1", + "web", + "run", + &json!({ + "search_query": [{"q": "standalone web search"}], + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let auth = CodexAuth::from_api_key("dummy"); + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); + let mut extension_builder = ExtensionRegistryBuilder::::new(); + install_web_search_extension(&mut extension_builder, auth_manager); + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(Arc::new(extension_builder.build())) + .with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::StandaloneWebSearch) + .expect("standalone web search should be enabled"); + config.memories.disable_on_external_context = true; + config + .web_search_mode + .set(WebSearchMode::Live) + .expect("web search mode should be accepted"); + }); + let test = builder.build(&server).await?; + let db = test.codex.state_db().expect("state db enabled"); + let thread_id = test.session_configured.thread_id; + + test.submit_turn("search the web").await?; + + let mut memory_mode = None; + for _ in 0..100 { + memory_mode = db.get_thread_memory_mode(thread_id).await?; + if memory_mode.as_deref() == Some("polluted") { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + assert_eq!(memory_mode.as_deref(), Some("polluted")); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 17234b2f1d..b8b87a85ec 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -868,7 +868,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { thread_id: primary_thread_id_for_span.clone(), client_user_message_id: None, input: items.into_iter().map(Into::into).collect(), - goal: false, responsesapi_client_metadata: None, additional_context: None, environments: None, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index b7b19dabe1..8706e8ee7a 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -10,16 +10,12 @@ use codex_tools::ToolExecutor; use crate::ExtensionData; -mod initial_goal; mod prompt; mod thread_lifecycle; mod tool_lifecycle; mod turn_input; mod turn_lifecycle; -pub use initial_goal::InitialGoalContributor; -pub use initial_goal::InitialGoalError; -pub use initial_goal::InitialGoalInput; pub use prompt::PromptFragment; pub use prompt::PromptSlot; pub use thread_lifecycle::ThreadIdleInput; diff --git a/codex-rs/ext/extension-api/src/contributors/initial_goal.rs b/codex-rs/ext/extension-api/src/contributors/initial_goal.rs deleted file mode 100644 index 3c9363e1e9..0000000000 --- a/codex-rs/ext/extension-api/src/contributors/initial_goal.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::future::Future; -use std::pin::Pin; - -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::protocol::InitialGoal; - -use crate::ExtensionData; - -/// Input supplied before the host commits settings or starts an initial goal turn. -pub struct InitialGoalInput<'a> { - /// Stable host-owned turn identifier. - pub turn_id: &'a str, - /// Goal objective requested for this turn. - pub goal: &'a InitialGoal, - /// Effective collaboration mode prepared for this turn. - pub collaboration_mode: &'a CollaborationMode, - /// Store scoped to the host session runtime. - pub session_store: &'a ExtensionData, - /// Store scoped to this thread runtime. - pub thread_store: &'a ExtensionData, -} - -/// Error returned while preparing an initial goal turn. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum InitialGoalError { - /// The request is invalid and should be reported to the caller. - InvalidRequest(String), - /// Goal persistence or runtime preparation failed internally. - Internal(String), -} - -/// Extension contribution that atomically replaces a goal before a turn starts. -pub trait InitialGoalContributor: Send + Sync { - /// Persist and prepare the requested goal before the host commits the turn. - fn replace_for_turn<'a>( - &'a self, - input: InitialGoalInput<'a>, - ) -> Pin> + Send + 'a>>; -} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 1b0cec8890..7fa60c0fe7 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -31,9 +31,6 @@ pub use codex_tools::parse_tool_input_schema_without_compaction; pub use contributors::ApprovalReviewContributor; pub use contributors::ConfigContributor; pub use contributors::ContextContributor; -pub use contributors::InitialGoalContributor; -pub use contributors::InitialGoalError; -pub use contributors::InitialGoalInput; pub use contributors::PromptFragment; pub use contributors::PromptSlot; pub use contributors::ThreadIdleInput; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 108077ecd7..0849387110 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -7,7 +7,6 @@ use crate::ConfigContributor; use crate::ContextContributor; use crate::ExtensionData; use crate::ExtensionEventSink; -use crate::InitialGoalContributor; use crate::NoopExtensionEventSink; use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; @@ -20,7 +19,6 @@ use crate::TurnLifecycleContributor; /// Mutable registry used while hosts register typed runtime contributions. pub struct ExtensionRegistryBuilder { event_sink: Arc, - initial_goal_contributor: Option>, thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -37,7 +35,6 @@ impl Default for ExtensionRegistryBuilder { fn default() -> Self { Self { event_sink: Arc::new(NoopExtensionEventSink), - initial_goal_contributor: None, thread_lifecycle_contributors: Vec::new(), turn_lifecycle_contributors: Vec::new(), config_contributors: Vec::new(), @@ -84,11 +81,6 @@ impl ExtensionRegistryBuilder { self.thread_lifecycle_contributors.push(contributor); } - /// Registers the extension that owns persisted thread goals. - pub fn initial_goal_contributor(&mut self, contributor: Arc) { - self.initial_goal_contributor = Some(contributor); - } - /// Registers one turn-lifecycle contributor. pub fn turn_lifecycle_contributor(&mut self, contributor: Arc) { self.turn_lifecycle_contributors.push(contributor); @@ -133,7 +125,6 @@ impl ExtensionRegistryBuilder { pub fn build(self) -> ExtensionRegistry { ExtensionRegistry { event_sink: self.event_sink, - initial_goal_contributor: self.initial_goal_contributor, thread_lifecycle_contributors: self.thread_lifecycle_contributors, turn_lifecycle_contributors: self.turn_lifecycle_contributors, config_contributors: self.config_contributors, @@ -151,7 +142,6 @@ impl ExtensionRegistryBuilder { /// Immutable typed registry produced after extensions are installed. pub struct ExtensionRegistry { event_sink: Arc, - initial_goal_contributor: Option>, thread_lifecycle_contributors: Vec>>, turn_lifecycle_contributors: Vec>, config_contributors: Vec>>, @@ -170,11 +160,6 @@ impl ExtensionRegistry { Arc::clone(&self.event_sink) } - /// Returns the extension that owns initial goal replacement, when installed. - pub fn initial_goal_contributor(&self) -> Option<&Arc> { - self.initial_goal_contributor.as_ref() - } - /// Returns the registered thread-lifecycle contributors. pub fn thread_lifecycle_contributors(&self) -> &[Arc>] { &self.thread_lifecycle_contributors diff --git a/codex-rs/ext/goal/src/api.rs b/codex-rs/ext/goal/src/api.rs index 58211f835f..5123e6a5ce 100644 --- a/codex-rs/ext/goal/src/api.rs +++ b/codex-rs/ext/goal/src/api.rs @@ -236,71 +236,6 @@ impl GoalService { }) } - pub(crate) async fn replace_goal_for_turn_start( - &self, - state_db: &codex_state::StateRuntime, - thread_id: ThreadId, - objective: &str, - ) -> Result { - let objective = objective.trim(); - validate_thread_goal_objective(objective).map_err(GoalServiceError::InvalidRequest)?; - - let runtime = self.runtime_for_thread(thread_id); - let _goal_state_permit = match runtime.as_ref() { - Some(runtime) => Some( - runtime - .goal_state_permit() - .await - .map_err(GoalServiceError::Internal)?, - ), - None => None, - }; - let previous_state_goal = state_db - .thread_goals() - .get_thread_goal(thread_id) - .await - .map_err(|err| { - GoalServiceError::Internal(format!("failed to read thread goal: {err}")) - })?; - let previous_goal = previous_state_goal.as_ref().map(PreviousGoalSnapshot::from); - if let Some(runtime) = runtime.as_ref() { - runtime - .prepare_external_goal_mutation() - .await - .map_err(GoalServiceError::Internal)?; - } - - let goal = match state_db - .thread_goals() - .replace_thread_goal( - thread_id, - objective, - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ None, - ) - .await - .map_err(|err| { - GoalServiceError::Internal(format!("failed to replace thread goal: {err}")) - }) { - Ok(goal) => goal, - Err(err) => { - if let Some(runtime) = runtime.as_ref() { - runtime.restore_goal_after_failed_mutation(previous_state_goal.as_ref()); - } - return Err(err); - } - }; - fill_empty_thread_preview_if_possible(state_db, thread_id, &goal).await; - if let Some(runtime) = runtime.as_ref() { - runtime.apply_initial_goal_set(&goal, previous_goal.as_ref()); - } - Ok(GoalSetOutcome { - goal: protocol_goal_from_state(goal.clone()), - state_goal: goal, - previous_goal, - }) - } - pub async fn clear_thread_goal( &self, state_db: &codex_state::StateRuntime, diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 55c8897840..78ae0b5b44 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -7,9 +7,6 @@ use codex_extension_api::ConfigContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::InitialGoalContributor; -use codex_extension_api::InitialGoalError; -use codex_extension_api::InitialGoalInput; use codex_extension_api::ThreadIdleInput; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadResumeInput; @@ -184,59 +181,6 @@ where } } -impl InitialGoalContributor for GoalExtension -where - C: Send + Sync + 'static, -{ - fn replace_for_turn<'a>( - &'a self, - input: InitialGoalInput<'a>, - ) -> std::pin::Pin< - Box> + Send + 'a>, - > { - Box::pin(async move { - let Some(runtime) = goal_runtime_handle(input.thread_store) else { - return Err(InitialGoalError::Internal( - "goal runtime is unavailable for this thread".to_string(), - )); - }; - if !runtime.is_enabled() { - return Err(InitialGoalError::InvalidRequest( - "goals feature is disabled".to_string(), - )); - } - if input.collaboration_mode.mode == codex_protocol::config_types::ModeKind::Plan { - return Err(InitialGoalError::InvalidRequest( - "goal turns do not support plan mode".to_string(), - )); - } - - let outcome = self - .goal_service - .replace_goal_for_turn_start( - &self.state_dbs, - runtime.thread_id(), - &input.goal.objective, - ) - .await - .map_err(|err| match err { - crate::api::GoalServiceError::InvalidRequest(message) => { - InitialGoalError::InvalidRequest(message) - } - crate::api::GoalServiceError::Internal(message) => { - InitialGoalError::Internal(message) - } - })?; - self.event_emitter.thread_goal_updated( - input.turn_id, - Some(input.turn_id.to_string()), - outcome.goal, - ); - Ok(()) - }) - } -} - #[async_trait] impl TurnLifecycleContributor for GoalExtension where @@ -499,7 +443,6 @@ pub fn install_with_backend( goals_enabled, )); registry.thread_lifecycle_contributor(extension.clone()); - registry.initial_goal_contributor(extension.clone()); registry.config_contributor(extension.clone()); registry.turn_lifecycle_contributor(extension.clone()); registry.token_usage_contributor(extension.clone()); diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 72076e64db..2641dfb949 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -211,38 +211,6 @@ impl GoalRuntimeHandle { Ok(()) } - pub(crate) fn apply_initial_goal_set( - &self, - goal: &codex_state::ThreadGoal, - previous_goal: Option<&PreviousGoalSnapshot>, - ) { - if !self.is_enabled() { - return; - } - - let replaced_existing_goal = - previous_goal.is_some_and(|previous_goal| previous_goal.goal_id != goal.goal_id); - if previous_goal.is_none() || replaced_existing_goal { - self.inner.metrics.record_created(); - } - self.inner - .accounting_state - .mark_idle_goal_active(goal.goal_id.clone()); - } - - pub(crate) fn restore_goal_after_failed_mutation( - &self, - goal: Option<&codex_state::ThreadGoal>, - ) { - match goal { - Some(goal) if goal.status == codex_state::ThreadGoalStatus::Active => self - .inner - .accounting_state - .mark_idle_goal_active(goal.goal_id.clone()), - Some(_) | None => self.inner.accounting_state.clear_active_goal(), - } - } - pub async fn apply_external_goal_clear(&self) -> Result<(), String> { if !self.is_enabled() { return Ok(()); diff --git a/codex-rs/ext/web-search/src/output.rs b/codex-rs/ext/web-search/src/output.rs index 799897b62d..543afdf7a9 100644 --- a/codex-rs/ext/web-search/src/output.rs +++ b/codex-rs/ext/web-search/src/output.rs @@ -23,9 +23,11 @@ impl ToolOutput for SearchOutput { true } + fn contains_external_context(&self) -> bool { + true + } + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - // TODO: Make standalone search honor memories.disable_on_external_context, - // as hosted web search does. ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index e09805d4d2..05a3004ee9 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -1,10 +1,7 @@ use crate::ConversationMessage; use crate::ImportedExternalAgentSession; use crate::MessageRole; -use crate::records::conversation_messages; -use crate::records::project_root_from_records; -use crate::records::read_records; -use crate::records::source_title_from_records; +use crate::records::read_session_import; use crate::summarize_for_label; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -23,44 +20,55 @@ use std::path::Path; const EXTERNAL_SESSION_IMPORTED_MARKER: &str = ""; -pub fn load_session_for_import(path: &Path) -> io::Result> { - let records = read_records(path)?; - let Some(cwd) = project_root_from_records(&records) else { +#[cfg(test)] +fn load_session_for_import(path: &Path) -> io::Result> { + Ok( + load_session_for_import_with_content_sha256(path)? + .map(|(session, _content_sha256)| session), + ) +} + +pub(crate) fn load_session_for_import_with_content_sha256( + path: &Path, +) -> io::Result> { + let parsed = read_session_import(path)?; + let Some(cwd) = parsed.cwd else { return Ok(None); }; - let messages = conversation_messages(&records); - let rollout_items = rollout_items_from_messages(&messages); + let messages = parsed.messages; + let first_user_message = messages + .iter() + .find(|message| message.role == MessageRole::User) + .map(|message| summarize_for_label(&message.text)); + let title = parsed.source_title.or_else(|| first_user_message.clone()); + let rollout_items = rollout_items_from_messages(messages); if rollout_items.is_empty() { return Ok(None); } - let title = source_title_from_records(&records).or_else(|| { - messages - .iter() - .find(|message| message.role == MessageRole::User) - .map(|message| summarize_for_label(&message.text)) - }); - Ok(Some(ImportedExternalAgentSession { - cwd, - title, - rollout_items, - })) + Ok(Some(( + ImportedExternalAgentSession { + cwd, + title, + first_user_message, + rollout_items, + }, + parsed.content_sha256, + ))) } -fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec { +fn rollout_items_from_messages(messages: Vec) -> Vec { let mut items = Vec::new(); - let mut response_items = Vec::new(); - let mut current_turn: Option<(String, Option)> = None; + let mut current_turn = None; + let mut response_item_bytes = 0i64; + let mut last_model_visible_tokens = 0i64; let mut user_turn_count = 0usize; + let completed_at = messages.last().and_then(|message| message.timestamp); for message in messages { match message.role { MessageRole::User => { - if let Some((turn_id, last_agent_message)) = current_turn.take() { - items.push(turn_complete_item( - turn_id, - last_agent_message, - /*completed_at*/ None, - )); + if let Some(turn_id) = current_turn.take() { + items.push(turn_complete_item(turn_id, /*completed_at*/ None)); } user_turn_count += 1; let turn_id = format!("external-import-turn-{user_turn_count}"); @@ -73,28 +81,24 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec { - let Some((_, last_agent_message)) = current_turn.as_mut() else { + if current_turn.is_none() { continue; - }; - let response_item = response_item(message); - response_items.push(response_item.clone()); - items.push(RolloutItem::ResponseItem(response_item)); + } + response_item_bytes = + response_item_bytes.saturating_add(message_byte_count(&message)); + last_model_visible_tokens = approx_tokens_from_byte_count_i64(response_item_bytes); items.push(RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { message: message.text.clone(), @@ -102,20 +106,15 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec RolloutItem { })) } -fn response_item(message: &ConversationMessage) -> ResponseItem { +fn response_item(message: ConversationMessage) -> ResponseItem { let content = match message.role { - MessageRole::Assistant => ContentItem::OutputText { - text: message.text.clone(), - }, - MessageRole::User => ContentItem::InputText { - text: message.text.clone(), - }, + MessageRole::Assistant => ContentItem::OutputText { text: message.text }, + MessageRole::User => ContentItem::InputText { text: message.text }, }; ResponseItem::Message { id: None, @@ -149,13 +144,11 @@ fn response_item(message: &ConversationMessage) -> ResponseItem { } } -fn token_count_item(response_items: &[ResponseItem]) -> RolloutItem { - let last_model_generated = response_items.iter().rposition( - |item| matches!(item, ResponseItem::Message { role, .. } if role == "assistant"), - ); - let last_model_visible_tokens = last_model_generated - .map(|index| estimate_response_items_token_count(&response_items[..=index])) - .unwrap_or_default(); +fn message_byte_count(message: &ConversationMessage) -> i64 { + i64::try_from(message.text.len()).unwrap_or(i64::MAX) +} + +fn token_count_item(last_model_visible_tokens: i64) -> RolloutItem { let usage = TokenUsage { total_tokens: last_model_visible_tokens, ..TokenUsage::default() @@ -170,26 +163,10 @@ fn token_count_item(response_items: &[ResponseItem]) -> RolloutItem { })) } -fn estimate_response_items_token_count(response_items: &[ResponseItem]) -> i64 { - response_items - .iter() - .map(|item| { - serde_json::to_string(item) - .map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX)) - .map(approx_tokens_from_byte_count_i64) - .unwrap_or_default() - }) - .fold(0i64, i64::saturating_add) -} - -fn turn_complete_item( - turn_id: String, - last_agent_message: Option, - completed_at: Option, -) -> RolloutItem { +fn turn_complete_item(turn_id: String, completed_at: Option) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, - last_agent_message, + last_agent_message: None, completed_at, duration_ms: None, time_to_first_token_ms: None, @@ -241,7 +218,7 @@ mod tests { } #[test] - fn adds_import_marker_without_replacing_last_agent_message() { + fn adds_import_marker_without_copying_last_agent_message() { let root = TempDir::new().expect("tempdir"); let project_root = root.path().join("repo"); std::fs::create_dir_all(&project_root).expect("project root"); @@ -280,10 +257,54 @@ mod tests { }); assert_eq!( last_turn_complete.and_then(|event| event.last_agent_message.as_deref()), - Some("first answer") + None ); } + #[test] + fn stores_imported_messages_as_response_items_and_visible_events() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let request = "r".repeat(1_000); + let answer = "a".repeat(1_000); + std::fs::write( + &path, + jsonl(&[ + record("user", &request, &project_root), + record("assistant", &answer, &project_root), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + let response_message_count = imported + .rollout_items + .iter() + .filter(|item| { + matches!( + item, + RolloutItem::ResponseItem(ResponseItem::Message { .. }) + ) + }) + .count(); + let visible_message_event_count = imported + .rollout_items + .iter() + .filter(|item| match item { + RolloutItem::EventMsg(EventMsg::UserMessage(event)) => event.message == request, + RolloutItem::EventMsg(EventMsg::AgentMessage(event)) => event.message == answer, + _ => false, + }) + .count(); + + assert_eq!(response_message_count, 2); + assert_eq!(visible_message_event_count, 2); + } + #[test] fn loads_custom_title_for_imported_session() { let root = TempDir::new().expect("tempdir"); diff --git a/codex-rs/external-agent-sessions/src/ledger.rs b/codex-rs/external-agent-sessions/src/ledger.rs index 45ff97bcf0..9a3b2042b8 100644 --- a/codex-rs/external-agent-sessions/src/ledger.rs +++ b/codex-rs/external-agent-sessions/src/ledger.rs @@ -30,6 +30,13 @@ struct ImportedExternalAgentSessionRecord { source_modified_at: Option, } +#[derive(Debug, PartialEq, Eq)] +pub struct CompletedExternalAgentSessionImport { + pub source_path: PathBuf, + pub source_content_sha256: String, + pub imported_thread_id: ThreadId, +} + #[derive(Debug, Clone, Copy)] pub(super) struct ImportedSourceState { pub source_modified_at: Option, @@ -43,29 +50,50 @@ pub fn has_current_session_been_imported( load_import_ledger(codex_home)?.contains_current_source(source_path) } -pub fn record_imported_session( +#[cfg(test)] +pub(crate) fn record_imported_session( codex_home: &Path, source_path: &Path, imported_thread_id: ThreadId, ) -> io::Result<()> { - let mut ledger = load_import_ledger(codex_home)?; let source_path = canonical_source_path(source_path)?; - let content_sha256 = session_content_sha256(&source_path)?; - let source_modified_at = session_modified_at(&source_path)?; - if let Some(index) = ledger.records.iter().rposition(|record| { - record.source_path == source_path && record.content_sha256 == content_sha256 - }) { - let mut record = ledger.records.remove(index); - record.imported_thread_id = imported_thread_id; - record.imported_at = now_unix_seconds(); - record.source_modified_at = source_modified_at; - ledger.records.push(record); - } else { - ledger.records.push(ImportedExternalAgentSessionRecord { + record_completed_session_imports( + codex_home, + vec![CompletedExternalAgentSessionImport { + source_content_sha256: session_content_sha256(&source_path)?, source_path, - content_sha256, imported_thread_id, - imported_at: now_unix_seconds(), + }], + ) +} + +pub fn record_completed_session_imports( + codex_home: &Path, + imports: Vec, +) -> io::Result<()> { + if imports.is_empty() { + return Ok(()); + } + let mut ledger = load_import_ledger(codex_home)?; + let imported_at = now_unix_seconds(); + for import in imports { + let source_modified_at = session_modified_at(&import.source_path).ok().flatten(); + if let Some(index) = ledger.records.iter().rposition(|record| { + record.source_path == import.source_path + && record.content_sha256 == import.source_content_sha256 + }) { + let mut record = ledger.records.remove(index); + record.imported_thread_id = import.imported_thread_id; + record.imported_at = imported_at; + record.source_modified_at = source_modified_at.or(record.source_modified_at); + ledger.records.push(record); + continue; + } + ledger.records.push(ImportedExternalAgentSessionRecord { + source_path: import.source_path, + content_sha256: import.source_content_sha256, + imported_thread_id: import.imported_thread_id, + imported_at, source_modified_at, }); } @@ -88,6 +116,9 @@ impl ImportedExternalAgentSessionLedger { } pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result { + if self.records.is_empty() { + return Ok(false); + } let source_path = canonical_source_path(source_path)?; if !self .records @@ -188,3 +219,7 @@ fn session_modified_at(path: &Path) -> io::Result> { .ok() .and_then(|duration| i64::try_from(duration.as_nanos()).ok())) } + +#[cfg(test)] +#[path = "ledger_tests.rs"] +mod tests; diff --git a/codex-rs/external-agent-sessions/src/ledger_tests.rs b/codex-rs/external-agent-sessions/src/ledger_tests.rs new file mode 100644 index 0000000000..f4b9dd6f7c --- /dev/null +++ b/codex-rs/external-agent-sessions/src/ledger_tests.rs @@ -0,0 +1,85 @@ +use super::CompletedExternalAgentSessionImport; +use super::ImportedExternalAgentSessionLedger; +use super::record_completed_session_imports; +use codex_protocol::ThreadId; +use sha2::Digest; +use sha2::Sha256; +use tempfile::TempDir; + +#[test] +fn empty_ledger_does_not_read_source() { + let root = TempDir::new().expect("tempdir"); + let missing_source = root.path().join("missing-session.jsonl"); + + assert!( + !ImportedExternalAgentSessionLedger::default() + .contains_current_source(&missing_source) + .expect("empty ledger cannot contain sources") + ); +} + +#[test] +fn completed_imports_do_not_read_source_files() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let source_path = root.path().join("session.jsonl"); + let contents = b"session contents"; + std::fs::write(&source_path, contents).expect("source"); + let source_path = std::fs::canonicalize(&source_path).expect("canonical source"); + std::fs::remove_file(&source_path).expect("remove source"); + let imported_thread_id = ThreadId::new(); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: source_path.clone(), + source_content_sha256: format!("{:x}", Sha256::digest(contents)), + imported_thread_id, + }], + ) + .expect("record completed imports"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].source_path, source_path); + assert_eq!(ledger.records[0].imported_thread_id, imported_thread_id); + assert_eq!(ledger.records[0].source_modified_at, None); +} + +#[test] +fn completed_import_refreshes_existing_record_metadata() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let source_path = root.path().join("session.jsonl"); + let contents = b"session contents"; + std::fs::write(&source_path, contents).expect("source"); + let source_path = std::fs::canonicalize(source_path).expect("canonical source"); + let content_sha256 = format!("{:x}", Sha256::digest(contents)); + let first_thread_id = ThreadId::new(); + let second_thread_id = ThreadId::new(); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: source_path.clone(), + source_content_sha256: content_sha256.clone(), + imported_thread_id: first_thread_id, + }], + ) + .expect("record first import"); + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: source_path.clone(), + source_content_sha256: content_sha256, + imported_thread_id: second_thread_id, + }], + ) + .expect("record replacement import"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].source_path, source_path); + assert_eq!(ledger.records[0].imported_thread_id, second_thread_id); + assert!(ledger.records[0].source_modified_at.is_some()); +} diff --git a/codex-rs/external-agent-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index fe9699f0c1..0b7a4eb2ba 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -6,15 +6,15 @@ mod ledger; mod records; use codex_protocol::protocol::RolloutItem; -use std::collections::HashSet; use std::io; use std::path::Path; use std::path::PathBuf; pub use detect::detect_recent_sessions; -pub use export::load_session_for_import; +use export::load_session_for_import_with_content_sha256; +pub use ledger::CompletedExternalAgentSessionImport; pub use ledger::has_current_session_been_imported; -pub use ledger::record_imported_session; +pub use ledger::record_completed_session_imports; pub use records::SessionSummary; pub use records::summarize_session; @@ -31,105 +31,51 @@ pub struct ExternalAgentSessionMigration { pub struct ImportedExternalAgentSession { pub cwd: PathBuf, pub title: Option, + pub first_user_message: Option, pub rollout_items: Vec, } #[derive(Debug, Clone)] pub struct PendingSessionImport { pub source_path: PathBuf, + pub source_content_sha256: String, pub session: ImportedExternalAgentSession, } -#[derive(Debug)] -pub enum PrepareSessionImportsError { - SessionNotDetected(PathBuf), -} - -impl std::fmt::Display for PrepareSessionImportsError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PrepareSessionImportsError::SessionNotDetected(path) => { - write!( - formatter, - "external agent session was not detected for import: {}", - path.display() - ) - } - } - } -} - -impl std::error::Error for PrepareSessionImportsError {} - -pub fn prepare_pending_session_imports( - codex_home: &Path, - requested_sessions: Vec, - detected_sessions: Vec, -) -> Result, PrepareSessionImportsError> { - let detected_session_paths = detected_sessions - .into_iter() - .map(|session| session.path) - .collect::>(); - let mut pending_session_imports = Vec::new(); - for session in requested_sessions { - let has_been_imported = match has_current_session_been_imported(codex_home, &session.path) { - Ok(has_been_imported) => has_been_imported, - Err(_) => continue, - }; - if !detected_session_paths.contains(&session.path) && !has_been_imported { - return Err(PrepareSessionImportsError::SessionNotDetected(session.path)); - } - if has_been_imported { - continue; - } - let imported_session = match load_importable_session(&session.path) { - Ok(Some(imported_session)) => imported_session, - Ok(None) | Err(_) => continue, - }; - pending_session_imports.push(PendingSessionImport { - source_path: session.path, - session: imported_session, - }); - } - Ok(pending_session_imports) -} - -pub fn prepare_validated_session_imports( - codex_home: &Path, - requested_sessions: Vec, -) -> Vec { - requested_sessions - .into_iter() - .filter_map(|session| pending_session_import(codex_home, session)) - .collect() -} - -fn pending_session_import( +pub fn prepare_validated_session_import( codex_home: &Path, session: ExternalAgentSessionMigration, -) -> Option { - let has_been_imported = match has_current_session_been_imported(codex_home, &session.path) { - Ok(has_been_imported) => has_been_imported, - Err(_) => return None, - }; +) -> io::Result> { + let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?; if has_been_imported { - return None; + return Ok(None); } - let imported_session = match load_importable_session(&session.path) { - Ok(Some(imported_session)) => imported_session, - Ok(None) | Err(_) => return None, - }; - Some(PendingSessionImport { - source_path: session.path, - session: imported_session, - }) -} - -fn load_importable_session(path: &Path) -> io::Result> { - let Some(imported_session) = load_session_for_import(path)? else { + let Some((source_path, imported_session, source_content_sha256)) = + load_importable_session(&session.path)? + else { return Ok(None); }; - Ok(imported_session.cwd.is_dir().then_some(imported_session)) + Ok(Some(PendingSessionImport { + source_path, + source_content_sha256, + session: imported_session, + })) +} + +fn load_importable_session( + path: &Path, +) -> io::Result> { + let source_path = std::fs::canonicalize(path)?; + let Some((imported_session, source_content_sha256)) = + load_session_for_import_with_content_sha256(&source_path)? + else { + return Ok(None); + }; + Ok(imported_session.cwd.is_dir().then_some(( + source_path, + imported_session, + source_content_sha256, + ))) } #[derive(Debug, Clone)] @@ -172,45 +118,59 @@ fn now_unix_seconds() -> i64 { mod tests { use super::*; use codex_protocol::ThreadId; + use sha2::Digest; + use sha2::Sha256; use tempfile::TempDir; - #[test] - fn rejects_session_that_was_not_detected() { - let root = TempDir::new().expect("tempdir"); - let codex_home = root.path().join("codex-home"); - let source_path = root.path().join("session.jsonl"); - std::fs::write(&source_path, "{}\n").expect("session"); - - let err = prepare_pending_session_imports( - &codex_home, - vec![session_migration(&source_path)], - Vec::new(), - ) - .expect_err("undetected session should be rejected"); - - match err { - PrepareSessionImportsError::SessionNotDetected(path) => { - assert_eq!(path, source_path); - } - } - } - #[test] fn skips_session_that_was_already_imported() { let root = TempDir::new().expect("tempdir"); let codex_home = root.path().join("codex-home"); let source_path = root.path().join("session.jsonl"); std::fs::write(&source_path, "{}\n").expect("session"); - record_imported_session(&codex_home, &source_path, ThreadId::new()).expect("record import"); + ledger::record_imported_session(&codex_home, &source_path, ThreadId::new()) + .expect("record import"); - let pending = prepare_pending_session_imports( - &codex_home, - vec![session_migration(&source_path)], - Vec::new(), - ) - .expect("already imported session should be skipped"); + let pending = + prepare_validated_session_import(&codex_home, session_migration(&source_path)) + .expect("already imported session should be skipped"); - assert!(pending.is_empty()); + assert!(pending.is_none()); + } + + #[test] + fn reports_session_preparation_errors() { + let root = TempDir::new().expect("tempdir"); + let source_path = root.path().join("missing-session.jsonl"); + + let err = prepare_validated_session_import(root.path(), session_migration(&source_path)) + .expect_err("missing session should fail preparation"); + + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn prepares_one_validated_session_import_with_content_hash() { + let root = TempDir::new().expect("tempdir"); + let source_path = root.path().join("session.jsonl"); + let contents = serde_json::json!({ + "type": "user", + "cwd": root.path(), + "timestamp": "2026-06-03T12:00:00Z", + "message": { "content": "first request" }, + }) + .to_string(); + std::fs::write(&source_path, &contents).expect("session"); + + let pending = + prepare_validated_session_import(root.path(), session_migration(&source_path)) + .expect("prepare session") + .expect("pending import"); + + assert_eq!( + pending.source_content_sha256, + format!("{:x}", Sha256::digest(contents)) + ); } fn session_migration(path: &Path) -> ExternalAgentSessionMigration { diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 52f0535452..00307fa1d5 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -4,6 +4,8 @@ use crate::MessageRole; use crate::summarize_for_label; use crate::truncate; use serde_json::Value as JsonValue; +use sha2::Digest; +use sha2::Sha256; use std::fs::File; use std::io; use std::io::BufRead; @@ -21,6 +23,13 @@ pub struct SessionSummary { pub migration: ExternalAgentSessionMigration, } +pub(super) struct ParsedSessionImport { + pub cwd: Option, + pub source_title: Option, + pub messages: Vec, + pub content_sha256: String, +} + pub fn summarize_session(path: &Path) -> io::Result> { let file = File::open(path)?; let reader = BufReader::new(file); @@ -37,7 +46,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { if trimmed.is_empty() { continue; } - let Ok(record) = serde_json::from_str::(trimmed) else { + let Ok(mut record) = serde_json::from_str::(trimmed) else { continue; }; if cwd.is_none() { @@ -52,7 +61,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { if let Some(title) = ai_title_from_record(&record) { ai_title = Some(title.to_string()); } - let Some(message) = conversation_message_from_record(&record) else { + let Some(message) = conversation_message_from_owned_record(&mut record) else { continue; }; saw_message = true; @@ -84,54 +93,50 @@ pub fn summarize_session(path: &Path) -> io::Result> { })) } -pub(super) fn source_title_from_records(records: &[JsonValue]) -> Option { - latest_title_from_records(records, custom_title_from_record) - .or_else(|| latest_title_from_records(records, ai_title_from_record)) -} - -pub(super) fn read_records(path: &Path) -> io::Result> { +pub(super) fn read_session_import(path: &Path) -> io::Result { let file = File::open(path)?; - let reader = BufReader::new(file); - let mut records = Vec::new(); - for line in reader.lines() { - let line = line?; + let mut reader = BufReader::new(file); + let mut cwd = None; + let mut custom_title = None; + let mut ai_title = None; + let mut messages = Vec::new(); + let mut line = String::new(); + let mut hasher = Sha256::new(); + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + break; + } + hasher.update(line.as_bytes()); let trimmed = line.trim(); if trimmed.is_empty() { continue; } - let Ok(value) = serde_json::from_str::(trimmed) else { + let Ok(mut record) = serde_json::from_str::(trimmed) else { continue; }; - if value.is_object() { - records.push(value); + if cwd.is_none() { + cwd = record + .get("cwd") + .and_then(JsonValue::as_str) + .map(PathBuf::from); + } + if let Some(title) = custom_title_from_record(&record) { + custom_title = Some(title.to_string()); + } + if let Some(title) = ai_title_from_record(&record) { + ai_title = Some(title.to_string()); + } + if let Some(message) = conversation_message_from_owned_record(&mut record) { + messages.push(message); } } - Ok(records) -} - -pub(super) fn project_root_from_records(records: &[JsonValue]) -> Option { - records - .iter() - .find_map(|record| record.get("cwd").and_then(JsonValue::as_str)) - .map(PathBuf::from) -} - -pub(super) fn conversation_messages(records: &[JsonValue]) -> Vec { - records - .iter() - .filter_map(conversation_message_from_record) - .collect() -} - -fn latest_title_from_records<'a>( - records: &'a [JsonValue], - title_from_record: impl Fn(&'a JsonValue) -> Option<&'a str>, -) -> Option { - records - .iter() - .filter_map(title_from_record) - .next_back() - .map(ToOwned::to_owned) + Ok(ParsedSessionImport { + cwd, + source_title: custom_title.or(ai_title), + messages, + content_sha256: format!("{:x}", hasher.finalize()), + }) } fn custom_title_from_record(record: &JsonValue) -> Option<&str> { @@ -150,7 +155,7 @@ fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str) .filter(|title| !title.is_empty()) } -fn conversation_message_from_record(record: &JsonValue) -> Option { +fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option { let record_type = record.get("type")?.as_str()?; if record_type != "assistant" && record_type != "user" { return None; @@ -161,18 +166,30 @@ fn conversation_message_from_record(record: &JsonValue) -> Option { + if text.trim().is_empty() { + return None; + } + ExtractedMessage { + text, + only_tool_result: false, + } + } + content => extract_message_text(&content)?, + }; Some(ConversationMessage { - role, + role: if is_assistant || extracted.only_tool_result { + MessageRole::Assistant + } else { + MessageRole::User + }, text: extracted.text, timestamp, }) @@ -324,6 +341,46 @@ fn parse_timestamp(timestamp: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; + + #[test] + fn reads_session_import_in_one_pass() { + let root = TempDir::new().expect("tempdir"); + let path = root.path().join("session.jsonl"); + let contents = [ + serde_json::json!({ + "type": "user", + "cwd": root.path(), + "timestamp": "2026-06-03T12:00:00Z", + "message": { "content": "first request" }, + }) + .to_string(), + "not json".to_string(), + serde_json::json!({ + "type": "ai-title", + "aiTitle": "generated title", + }) + .to_string(), + serde_json::json!({ + "type": "custom-title", + "customTitle": "custom title", + }) + .to_string(), + ] + .join("\n"); + std::fs::write(&path, &contents).expect("session"); + + let parsed = read_session_import(&path).expect("parse session"); + + assert_eq!(parsed.cwd.as_deref(), Some(root.path())); + assert_eq!(parsed.source_title.as_deref(), Some("custom title")); + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].text, "first request"); + assert_eq!( + parsed.content_sha256, + format!("{:x}", Sha256::digest(contents)) + ); + } #[test] fn converts_tool_use_blocks_to_bounded_external_agent_tags() { diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 92b5e5a775..fd56805d4e 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -115,7 +115,6 @@ pub async fn run_codex_tool_session( thread_settings: Default::default(), }, client_user_message_id: None, - initial_goal: None, trace: None, }; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 6ddcd3ebcc..2f85b35143 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -540,7 +540,6 @@ impl MessageProcessor { id: request_id_string, op: codex_protocol::protocol::Op::Interrupt, client_user_message_id: None, - initial_goal: None, trace: None, }) .await diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d8f7ac5fd7..c9e2e8bb28 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -132,17 +132,10 @@ pub struct Submission { pub op: Op, /// Client-provided id for the user message represented by `Op::UserInput`. pub client_user_message_id: Option, - /// Goal to replace before processing this user-input submission. - pub initial_goal: Option, /// Optional W3C trace carrier propagated across async submission handoffs. pub trace: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -pub struct InitialGoal { - pub objective: String, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct W3cTraceContext { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index b61bb80de8..fe57f23ce9 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -18,6 +18,12 @@ pub trait ToolOutput: Send { fn success_for_logging(&self) -> bool; + /// Whether this output contains external context that should disable memory generation when + /// `memories.disable_on_external_context` is enabled. + fn contains_external_context(&self) -> bool { + false + } + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; /// Returns the tool call id exposed to `PostToolUse` hooks for this output. @@ -58,6 +64,10 @@ where (**self).success_for_logging() } + fn contains_external_context(&self) -> bool { + (**self).contains_external_context() + } + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { (**self).to_response_item(call_id, payload) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 0a175bc15d..32db3d2cf7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -710,7 +710,6 @@ impl AppServerSession { thread_id: thread_id.to_string(), client_user_message_id: None, input: items, - goal: false, responsesapi_client_metadata: None, additional_context: None, environments: None, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 2bc193a5e5..15ede1801c 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -6956,12 +6956,6 @@ class TurnStartParams(BaseModel): ReasoningEffort | None, Field(description="Override the reasoning effort for this turn and subsequent turns."), ] = None - goal: Annotated[ - bool | None, - Field( - description="Replace the thread's active goal with an objective derived from this turn's text input." - ), - ] = False input: list[UserInput] model: Annotated[ str | None, Field(description="Override the model for this turn and subsequent turns.")