Merge remote-tracking branch 'origin/main' into pakrym/async-fs-trust-migration

# Conflicts:
#	codex-rs/core/src/git_info_tests.rs
#	codex-rs/core/src/realtime_context.rs
#	codex-rs/core/src/tools/spec_tests.rs
#	codex-rs/git-utils/src/info.rs
#	codex-rs/tui/src/onboarding/trust_directory.rs
#	codex-rs/tui_app_server/src/onboarding/trust_directory.rs
This commit is contained in:
pakrym-oai
2026-03-24 15:54:04 -07:00
182 changed files with 4279 additions and 2721 deletions
+23
View File
@@ -0,0 +1,23 @@
{
"outputs": {
"codex-zsh": {
"platforms": {
"macos-aarch64": {
"name": "codex-zsh-aarch64-apple-darwin.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"linux-x86_64": {
"name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
},
"linux-aarch64": {
"name": "codex-zsh-aarch64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
"path": "codex-zsh/bin/zsh"
}
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 ]]; then
echo "usage: $0 <archive-path>" >&2
exit 1
fi
archive_path="$1"
workspace="${GITHUB_WORKSPACE:?missing GITHUB_WORKSPACE}"
zsh_commit="${ZSH_COMMIT:?missing ZSH_COMMIT}"
zsh_patch="${ZSH_PATCH:?missing ZSH_PATCH}"
temp_root="${RUNNER_TEMP:-/tmp}"
work_root="$(mktemp -d "${temp_root%/}/codex-zsh-release.XXXXXX")"
trap 'rm -rf "$work_root"' EXIT
source_root="${work_root}/zsh"
package_root="${work_root}/codex-zsh"
wrapper_path="${work_root}/exec-wrapper"
stdout_path="${work_root}/stdout.txt"
wrapper_log_path="${work_root}/wrapper.log"
git clone https://git.code.sf.net/p/zsh/code "$source_root"
cd "$source_root"
git checkout "$zsh_commit"
git apply "${workspace}/${zsh_patch}"
./Util/preconfig
./configure
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
cat > "$wrapper_path" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$wrapper_path"
CODEX_WRAPPER_LOG="$wrapper_log_path" \
EXEC_WRAPPER="$wrapper_path" \
"${source_root}/Src/zsh" -fc '/bin/echo smoke-zsh' > "$stdout_path"
grep -Fx "smoke-zsh" "$stdout_path"
grep -Fx "/bin/echo" "$wrapper_log_path"
mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")"
cp "${source_root}/Src/zsh" "$package_root/bin/zsh"
chmod +x "$package_root/bin/zsh"
(cd "$work_root" && tar -czf "${workspace}/${archive_path}" codex-zsh)
+95
View File
@@ -0,0 +1,95 @@
name: rust-release-zsh
on:
workflow_call:
env:
ZSH_COMMIT: 77045ef899e53b9598bebc5a41db93a548a40ca6
ZSH_PATCH: codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch
jobs:
linux:
name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
archive_name: codex-zsh-x86_64-unknown-linux-musl.tar.gz
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
archive_name: codex-zsh-aarch64-unknown-linux-musl.tar.gz
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
autoconf \
bison \
build-essential \
ca-certificates \
gettext \
git \
libncursesw5-dev
- uses: actions/checkout@v6
- name: Build, smoke-test, and stage zsh artifact
shell: bash
run: |
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@v7
with:
name: codex-zsh-${{ matrix.target }}
path: dist/zsh/${{ matrix.target }}/*
darwin:
name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
archive_name: codex-zsh-aarch64-apple-darwin.tar.gz
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if ! command -v autoconf >/dev/null 2>&1; then
brew install autoconf
fi
- uses: actions/checkout@v6
- name: Build, smoke-test, and stage zsh artifact
shell: bash
run: |
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@v7
with:
name: codex-zsh-${{ matrix.target }}
path: dist/zsh/${{ matrix.target }}/*
+13 -13
View File
@@ -389,15 +389,6 @@ jobs:
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
secrets: inherit
shell-tool-mcp:
name: shell-tool-mcp
needs: tag-check
uses: ./.github/workflows/shell-tool-mcp.yml
with:
release-tag: ${{ github.ref_name }}
publish: true
secrets: inherit
argument-comment-lint-release-assets:
name: argument-comment-lint release assets
needs: tag-check
@@ -405,12 +396,17 @@ jobs:
with:
publish: true
zsh-release-assets:
name: zsh release assets
needs: tag-check
uses: ./.github/workflows/rust-release-zsh.yml
release:
needs:
- build
- build-windows
- shell-tool-mcp
- argument-comment-lint-release-assets
- zsh-release-assets
name: release
runs-on: ubuntu-latest
permissions:
@@ -453,11 +449,8 @@ jobs:
- name: List
run: ls -R dist/
# This is a temporary fix: we should modify shell-tool-mcp.yml so these
# files do not end up in dist/ in the first place.
- name: Delete entries from dist/ that should not go in the release
run: |
rm -rf dist/shell-tool-mcp*
rm -rf dist/windows-binaries*
# cargo-timing.html appears under multiple target-specific directories.
# If included in files: dist/**, release upload races on duplicate
@@ -547,6 +540,13 @@ jobs:
tag: ${{ github.ref_name }}
config: .github/dotslash-config.json
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-zsh-config.json
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-48
View File
@@ -1,48 +0,0 @@
name: shell-tool-mcp CI
on:
push:
paths:
- "shell-tool-mcp/**"
- ".github/workflows/shell-tool-mcp-ci.yml"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
pull_request:
paths:
- "shell-tool-mcp/**"
- ".github/workflows/shell-tool-mcp-ci.yml"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
env:
NODE_VERSION: 22
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Format check
run: pnpm --filter @openai/codex-shell-tool-mcp run format
- name: Run tests
run: pnpm --filter @openai/codex-shell-tool-mcp test
- name: Build
run: pnpm --filter @openai/codex-shell-tool-mcp run build
-553
View File
@@ -1,553 +0,0 @@
name: shell-tool-mcp
on:
workflow_call:
inputs:
release-version:
description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v.
required: false
type: string
release-tag:
description: Tag name to use when downloading release artifacts (defaults to rust-v<version>).
required: false
type: string
publish:
description: Whether to publish to npm when the version is releasable.
required: false
default: true
type: boolean
env:
NODE_VERSION: 22
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.compute.outputs.version }}
release_tag: ${{ steps.compute.outputs.release_tag }}
should_publish: ${{ steps.compute.outputs.should_publish }}
npm_tag: ${{ steps.compute.outputs.npm_tag }}
steps:
- name: Compute version and tags
id: compute
env:
RELEASE_TAG_INPUT: ${{ inputs.release-tag }}
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
run: |
set -euo pipefail
version="$RELEASE_VERSION_INPUT"
release_tag="$RELEASE_TAG_INPUT"
if [[ -z "$version" ]]; then
if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then
version="${release_tag#rust-v}"
elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then
version="${GITHUB_REF_NAME#rust-v}"
release_tag="${GITHUB_REF_NAME}"
else
echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag."
exit 1
fi
fi
if [[ -z "$release_tag" ]]; then
release_tag="rust-v${version}"
fi
npm_tag=""
should_publish="false"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
should_publish="true"
elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
should_publish="true"
npm_tag="alpha"
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT"
echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT"
bash-linux:
name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-22.04
image: ubuntu:22.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-12
image: debian:12
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-11
image: debian:11
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-22.04
image: arm64v8/ubuntu:22.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-20.04
image: arm64v8/ubuntu:20.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-12
image: arm64v8/debian:12
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-11
image: arm64v8/debian:11
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
elif command -v dnf >/dev/null 2>&1; then
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
elif command -v yum >/dev/null 2>&1; then
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
else
echo "Unsupported package manager in container"
exit 1
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched Bash
shell: bash
run: |
set -euo pipefail
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
bash-darwin:
name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
- runner: macos-14
target: aarch64-apple-darwin
variant: macos-14
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched Bash
shell: bash
run: |
set -euo pipefail
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
cd /tmp/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
cores="$(getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
zsh-linux:
name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-22.04
image: ubuntu:22.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-12
image: debian:12
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-11
image: debian:11
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-22.04
image: arm64v8/ubuntu:22.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-20.04
image: arm64v8/ubuntu:20.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-12
image: arm64v8/debian:12
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-11
image: arm64v8/debian:11
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
elif command -v dnf >/dev/null 2>&1; then
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
elif command -v yum >/dev/null 2>&1; then
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
else
echo "Unsupported package manager in container"
exit 1
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched zsh
shell: bash
run: |
set -euo pipefail
git clone https://git.code.sf.net/p/zsh/code /tmp/zsh
cd /tmp/zsh
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
./Util/preconfig
./configure
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
mkdir -p "$dest"
cp Src/zsh "$dest/zsh"
- name: Smoke test zsh exec wrapper
shell: bash
run: |
set -euo pipefail
tmpdir="$(mktemp -d)"
cat > "$tmpdir/exec-wrapper" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$tmpdir/exec-wrapper"
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
zsh-darwin:
name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
- runner: macos-14
target: aarch64-apple-darwin
variant: macos-14
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if ! command -v autoconf >/dev/null 2>&1; then
brew install autoconf
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched zsh
shell: bash
run: |
set -euo pipefail
git clone https://git.code.sf.net/p/zsh/code /tmp/zsh
cd /tmp/zsh
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
./Util/preconfig
./configure
cores="$(getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
mkdir -p "$dest"
cp Src/zsh "$dest/zsh"
- name: Smoke test zsh exec wrapper
shell: bash
run: |
set -euo pipefail
tmpdir="$(mktemp -d)"
cat > "$tmpdir/exec-wrapper" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$tmpdir/exec-wrapper"
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v7
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
package:
name: Package npm module
needs:
- metadata
- bash-linux
- bash-darwin
- zsh-linux
- zsh-darwin
runs-on: ubuntu-latest
env:
PACKAGE_VERSION: ${{ needs.metadata.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install JavaScript dependencies
run: pnpm install --frozen-lockfile
- name: Build (shell-tool-mcp)
run: pnpm --filter @openai/codex-shell-tool-mcp run build
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Assemble staging directory
id: staging
shell: bash
run: |
set -euo pipefail
staging="${STAGING_DIR}"
mkdir -p "$staging" "$staging/vendor"
cp shell-tool-mcp/README.md "$staging/"
cp shell-tool-mcp/package.json "$staging/"
found_vendor="false"
shopt -s nullglob
for vendor_dir in artifacts/*/vendor; do
rsync -av "$vendor_dir/" "$staging/vendor/"
found_vendor="true"
done
if [[ "$found_vendor" == "false" ]]; then
echo "No vendor payloads were downloaded."
exit 1
fi
node - <<'NODE'
import fs from "node:fs";
import path from "node:path";
const stagingDir = process.env.STAGING_DIR;
const version = process.env.PACKAGE_VERSION;
const pkgPath = path.join(stagingDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
pkg.version = version;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
NODE
echo "dir=$staging" >> "$GITHUB_OUTPUT"
env:
STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp
- name: Ensure binaries are executable
env:
STAGING_DIR: ${{ steps.staging.outputs.dir }}
run: |
set -euo pipefail
chmod +x \
"$STAGING_DIR"/vendor/*/bash/*/bash \
"$STAGING_DIR"/vendor/*/zsh/*/zsh
- name: Create npm tarball
shell: bash
env:
STAGING_DIR: ${{ steps.staging.outputs.dir }}
run: |
set -euo pipefail
mkdir -p dist/npm
pack_info=$(cd "$STAGING_DIR" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);')
mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz"
- uses: actions/upload-artifact@v7
with:
name: codex-shell-tool-mcp-npm
path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz
if-no-files-found: error
publish:
name: Publish npm package
needs:
- metadata
- package
if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }}
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
scope: "@openai"
# Trusted publishing requires npm CLI version 11.5.1 or later.
- name: Update npm
run: npm install -g npm@latest
- name: Download npm tarball
uses: actions/download-artifact@v8
with:
name: codex-shell-tool-mcp-npm
path: dist/npm
- name: Publish to npm
env:
NPM_TAG: ${{ needs.metadata.outputs.npm_tag }}
VERSION: ${{ needs.metadata.outputs.version }}
shell: bash
run: |
set -euo pipefail
tag_args=()
if [[ -n "${NPM_TAG}" ]]; then
tag_args+=(--tag "${NPM_TAG}")
fi
npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}"
+26 -15
View File
@@ -380,7 +380,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1444,6 +1444,7 @@ dependencies = [
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
@@ -1509,6 +1510,7 @@ dependencies = [
"anyhow",
"clap",
"codex-experimental-api-macros",
"codex-git-utils",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
@@ -1643,7 +1645,7 @@ dependencies = [
"clap",
"codex-connectors",
"codex-core",
"codex-git",
"codex-git-utils",
"codex-utils-cargo-bin",
"codex-utils-cli",
"pretty_assertions",
@@ -1768,6 +1770,7 @@ dependencies = [
"codex-client",
"codex-cloud-tasks-client",
"codex-core",
"codex-git-utils",
"codex-login",
"codex-tui",
"codex-utils-cli",
@@ -1794,7 +1797,7 @@ dependencies = [
"async-trait",
"chrono",
"codex-backend-client",
"codex-git",
"codex-git-utils",
"diffy",
"serde",
"serde_json",
@@ -1879,7 +1882,7 @@ dependencies = [
"codex-execpolicy",
"codex-features",
"codex-file-search",
"codex-git",
"codex-git-utils",
"codex-hooks",
"codex-login",
"codex-network-proxy",
@@ -1993,6 +1996,7 @@ dependencies = [
"codex-cloud-requirements",
"codex-core",
"codex-feedback",
"codex-git-utils",
"codex-otel",
"codex-protocol",
"codex-utils-absolute-path",
@@ -2133,10 +2137,12 @@ dependencies = [
]
[[package]]
name = "codex-git"
name = "codex-git-utils"
version = "0.0.0"
dependencies = [
"assert_matches",
"codex-utils-absolute-path",
"futures",
"once_cell",
"pretty_assertions",
"regex",
@@ -2144,6 +2150,7 @@ dependencies = [
"serde",
"tempfile",
"thiserror 2.0.18",
"tokio",
"ts-rs",
"walkdir",
]
@@ -2389,7 +2396,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"codex-execpolicy",
"codex-git",
"codex-git-utils",
"codex-utils-absolute-path",
"codex-utils-image",
"icu_decimal",
@@ -2484,6 +2491,7 @@ dependencies = [
"age",
"anyhow",
"base64 0.22.1",
"codex-git-utils",
"codex-keyring-store",
"keyring",
"pretty_assertions",
@@ -2554,6 +2562,7 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"codex-git-utils",
"codex-protocol",
"dirs",
"log",
@@ -2620,6 +2629,7 @@ dependencies = [
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
@@ -2713,6 +2723,7 @@ dependencies = [
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
@@ -3836,7 +3847,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4081,7 +4092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5526,7 +5537,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6289,7 +6300,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6933,7 +6944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8355,7 +8366,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9788,7 +9799,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11234,7 +11245,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+2 -2
View File
@@ -50,7 +50,7 @@ members = [
"v8-poc",
"utils/absolute-path",
"utils/cargo-bin",
"utils/git",
"git-utils",
"utils/cache",
"utils/image",
"utils/json-to-toml",
@@ -117,7 +117,7 @@ codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-feedback = { path = "feedback" }
codex-features = { path = "features" }
codex-file-search = { path = "file-search" }
codex-git = { path = "utils/git" }
codex-git-utils = { path = "git-utils" }
codex-hooks = { path = "hooks" }
codex-keyring-store = { path = "keyring-store" }
codex-linux-sandbox = { path = "linux-sandbox" }
+2 -1
View File
@@ -14,8 +14,9 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-protocol = { workspace = true }
codex-experimental-api-macros = { workspace = true }
codex-git-utils = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -8557,6 +8557,21 @@
},
"type": "object"
},
"MarketplaceLoadErrorInfo": {
"properties": {
"marketplacePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"message": {
"type": "string"
}
},
"required": [
"marketplacePath",
"message"
],
"type": "object"
},
"McpAuthStatus": {
"enum": [
"unsupported",
@@ -9515,6 +9530,13 @@
},
"type": "array"
},
"marketplaceLoadErrors": {
"default": [],
"items": {
"$ref": "#/definitions/v2/MarketplaceLoadErrorInfo"
},
"type": "array"
},
"marketplaces": {
"items": {
"$ref": "#/definitions/v2/PluginMarketplaceEntry"
@@ -5305,6 +5305,21 @@
},
"type": "object"
},
"MarketplaceLoadErrorInfo": {
"properties": {
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"message": {
"type": "string"
}
},
"required": [
"marketplacePath",
"message"
],
"type": "object"
},
"McpAuthStatus": {
"enum": [
"unsupported",
@@ -6263,6 +6278,13 @@
},
"type": "array"
},
"marketplaceLoadErrors": {
"default": [],
"items": {
"$ref": "#/definitions/MarketplaceLoadErrorInfo"
},
"type": "array"
},
"marketplaces": {
"items": {
"$ref": "#/definitions/PluginMarketplaceEntry"
@@ -16,6 +16,21 @@
},
"type": "object"
},
"MarketplaceLoadErrorInfo": {
"properties": {
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"message": {
"type": "string"
}
},
"required": [
"marketplacePath",
"message"
],
"type": "object"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
@@ -246,6 +261,13 @@
},
"type": "array"
},
"marketplaceLoadErrors": {
"default": [],
"items": {
"$ref": "#/definitions/MarketplaceLoadErrorInfo"
},
"type": "array"
},
"marketplaces": {
"items": {
"$ref": "#/definitions/PluginMarketplaceEntry"
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type MarketplaceLoadErrorInfo = { marketplacePath: AbsolutePathBuf, message: string, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, remoteSyncError: string | null, featuredPluginIds: Array<string>, };
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>, remoteSyncError: string | null, featuredPluginIds: Array<string>, };
@@ -141,6 +141,7 @@ export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { MarketplaceInterface } from "./MarketplaceInterface";
export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
export type { McpAuthStatus } from "./McpAuthStatus";
export type { McpElicitationArrayType } from "./McpElicitationArrayType";
export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema";
+1
View File
@@ -4,6 +4,7 @@ mod jsonrpc_lite;
mod protocol;
mod schema_fixtures;
pub use codex_git_utils::GitSha;
pub use experimental_api::*;
pub use export::GenerateTsOptions;
pub use export::generate_internal_json_schema;
@@ -14,16 +14,6 @@ use serde::Serialize;
use strum_macros::Display;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
#[ts(type = "string")]
pub struct GitSha(pub String);
impl GitSha {
pub fn new(sha: &str) -> Self {
Self(sha.to_string())
}
}
/// Authentication mode for OpenAI-backed providers.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
@@ -21,7 +22,6 @@ use serde::Serialize;
use ts_rs::TS;
use crate::protocol::common::AuthMode;
use crate::protocol::common::GitSha;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -3146,11 +3146,21 @@ pub struct PluginListParams {
#[ts(export_to = "v2/")]
pub struct PluginListResponse {
pub marketplaces: Vec<PluginMarketplaceEntry>,
#[serde(default)]
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
pub remote_sync_error: Option<String>,
#[serde(default)]
pub featured_plugin_ids: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MarketplaceLoadErrorInfo {
pub marketplace_path: AbsolutePathBuf,
pub message: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1
View File
@@ -34,6 +34,7 @@ codex-cloud-requirements = { workspace = true }
codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-otel = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
+1 -1
View File
@@ -163,7 +163,7 @@ Example with notification opt-out:
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
@@ -213,7 +213,6 @@ use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_name_by_id;
use codex_core::find_thread_names_by_ids;
use codex_core::find_thread_path_by_id_str;
use codex_core::git_info::git_diff_to_remote;
use codex_core::mcp::auth::discover_supported_scopes;
use codex_core::mcp::auth::resolve_oauth_scopes;
use codex_core::mcp::collect_mcp_snapshot;
@@ -243,6 +242,7 @@ use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
use codex_feedback::CodexFeedback;
use codex_git_utils::git_diff_to_remote;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
@@ -409,6 +409,13 @@ enum EnsureConversationListenerResult {
ConnectionClosed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RefreshTokenRequestOutcome {
NotAttemptedOrSucceeded,
FailedTransiently,
FailedPermanently,
}
pub(crate) struct CodexMessageProcessorArgs {
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) thread_manager: Arc<ThreadManager>,
@@ -1338,13 +1345,19 @@ impl CodexMessageProcessor {
}
}
async fn refresh_token_if_requested(&self, do_refresh: bool) {
async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome {
if self.auth_manager.is_external_auth_active() {
return;
return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded;
}
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
tracing::warn!("failed to refresh token while getting account: {err}");
let failed_reason = err.failed_reason();
if failed_reason.is_none() {
tracing::warn!("failed to refresh token while getting account: {err}");
return RefreshTokenRequestOutcome::FailedTransiently;
}
return RefreshTokenRequestOutcome::FailedPermanently;
}
RefreshTokenRequestOutcome::NotAttemptedOrSucceeded
}
async fn get_auth_status(&self, request_id: ConnectionRequestId, params: GetAuthStatusParams) {
@@ -1367,18 +1380,25 @@ impl CodexMessageProcessor {
} else {
match self.auth_manager.auth().await {
Some(auth) => {
let permanent_refresh_failure =
self.auth_manager.refresh_failure_for_auth(&auth).is_some();
let auth_mode = auth.api_auth_mode();
let (reported_auth_method, token_opt) = match auth.get_token() {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
(Some(auth_mode), tok)
}
Ok(_) => (None, None),
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
(None, None)
}
};
let (reported_auth_method, token_opt) =
if include_token && permanent_refresh_failure {
(Some(auth_mode), None)
} else {
match auth.get_token() {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
(Some(auth_mode), tok)
}
Ok(_) => (None, None),
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
(None, None)
}
}
};
GetAuthStatusResponse {
auth_method: reported_auth_method,
auth_token: token_opt,
@@ -1911,6 +1931,13 @@ impl CodexMessageProcessor {
}
}
pub(crate) async fn cancel_active_login(&self) {
let mut guard = self.active_login.lock().await;
if let Some(active_login) = guard.take() {
drop(active_login);
}
}
pub(crate) async fn clear_all_thread_listeners(&self) {
self.thread_state_manager.clear_all_listeners().await;
}
@@ -5523,11 +5550,18 @@ impl CodexMessageProcessor {
let config_for_marketplace_listing = config.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let data = match tokio::task::spawn_blocking(move || {
let marketplaces = plugins_manager_for_marketplace_listing
let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
Ok::<Vec<PluginMarketplaceEntry>, MarketplaceError>(
marketplaces
Ok::<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
),
MarketplaceError,
>((
outcome
.marketplaces
.into_iter()
.map(|marketplace| PluginMarketplaceEntry {
name: marketplace.name,
@@ -5551,11 +5585,19 @@ impl CodexMessageProcessor {
.collect(),
})
.collect(),
)
outcome
.errors
.into_iter()
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
marketplace_path: err.path,
message: err.message,
})
.collect(),
))
})
.await
{
Ok(Ok(data)) => data,
Ok(Ok(outcome)) => outcome,
Ok(Err(err)) => {
self.send_marketplace_error(request_id, err, "list marketplace plugins")
.await;
@@ -5597,6 +5639,7 @@ impl CodexMessageProcessor {
request_id,
PluginListResponse {
marketplaces: data,
marketplace_load_errors,
remote_sync_error,
featured_plugin_ids,
},
@@ -8188,7 +8231,7 @@ fn extract_conversation_summary(
fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
ConversationGitInfo {
sha: git_info.commit_hash.clone(),
sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()),
branch: git_info.branch.clone(),
origin_url: git_info.repository_url.clone(),
}
@@ -8871,6 +8914,7 @@ mod tests {
request_id: sent_request_id,
..
}),
..
} = request_message
else {
panic!("expected tool request to be sent to the subscribed connection");
@@ -9,7 +9,7 @@ use codex_core::mcp::auth::McpOAuthLoginSupport;
use codex_core::mcp::auth::oauth_login_support;
use codex_core::mcp::auth::resolve_oauth_scopes;
use codex_core::mcp::auth::should_retry_without_scopes;
use codex_rmcp_client::perform_oauth_login;
use codex_rmcp_client::perform_oauth_login_silent;
use tracing::warn;
use super::CodexMessageProcessor;
@@ -45,7 +45,7 @@ impl CodexMessageProcessor {
let notification_name = name.clone();
tokio::spawn(async move {
let first_attempt = perform_oauth_login(
let first_attempt = perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
@@ -60,7 +60,7 @@ impl CodexMessageProcessor {
let final_result = match first_attempt {
Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => {
perform_oauth_login(
perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
+12 -3
View File
@@ -42,6 +42,7 @@ use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
const EXEC_TIMEOUT_EXIT_CODE: i32 = 124;
const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024;
#[derive(Clone)]
pub(crate) struct CommandExecManager {
@@ -577,13 +578,19 @@ fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHa
let mut buffer: Vec<u8> = Vec::new();
let mut observed_num_bytes = 0usize;
loop {
let chunk = tokio::select! {
let mut chunk = tokio::select! {
chunk = output_rx.recv() => match chunk {
Some(chunk) => chunk,
None => break,
},
_ = stdio_timeout_rx.wait_for(|&v| v) => break,
};
// Individual chunks are at most 8KiB, so overshooting a bit is acceptable.
while chunk.len() < OUTPUT_CHUNK_SIZE_HINT
&& let Ok(next_chunk) = output_rx.try_recv()
{
chunk.extend_from_slice(&next_chunk);
}
let capped_chunk = match output_bytes_cap {
Some(output_bytes_cap) => {
let capped_chunk_len = output_bytes_cap
@@ -597,8 +604,8 @@ fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHa
let cap_reached = Some(observed_num_bytes) == output_bytes_cap;
if let (true, Some(process_id)) = (stream_output, process_id.as_ref()) {
outgoing
.send_server_notification_to_connections(
&[connection_id],
.send_server_notification_to_connection_and_wait(
connection_id,
ServerNotification::CommandExecOutputDelta(
CommandExecOutputDeltaNotification {
process_id: process_id.clone(),
@@ -807,6 +814,7 @@ mod tests {
let OutgoingEnvelope::ToConnection {
connection_id,
message,
..
} = envelope
else {
panic!("expected connection-scoped outgoing message");
@@ -887,6 +895,7 @@ mod tests {
let OutgoingEnvelope::ToConnection {
connection_id,
message,
..
} = envelope
else {
panic!("expected connection-scoped outgoing message");
+9 -3
View File
@@ -60,6 +60,7 @@ use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::QueuedOutgoingMessage;
use crate::transport::CHANNEL_CAPACITY;
use crate::transport::OutboundConnectionState;
use crate::transport::route_outgoing_envelope;
@@ -353,7 +354,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(channel_capacity);
let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx));
let (writer_tx, mut writer_rx) = mpsc::channel::<OutgoingMessage>(channel_capacity);
let (writer_tx, mut writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(channel_capacity);
let outbound_initialized = Arc::new(AtomicBool::new(false));
let outbound_experimental_api_enabled = Arc::new(AtomicBool::new(false));
let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new()));
@@ -457,6 +458,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
}
processor.clear_runtime_references();
processor.cancel_active_login().await;
processor.connection_closed(IN_PROCESS_CONNECTION_ID).await;
processor.clear_all_thread_listeners().await;
processor.drain_background_tasks().await;
@@ -547,10 +549,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
}
}
}
outgoing_message = writer_rx.recv() => {
let Some(outgoing_message) = outgoing_message else {
queued_message = writer_rx.recv() => {
let Some(queued_message) = queued_message else {
break;
};
let outgoing_message = queued_message.message;
match outgoing_message {
OutgoingMessage::Response(response) => {
if let Some(response_tx) = pending_request_responses.remove(&response.id) {
@@ -629,6 +632,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
}
}
}
if let Some(write_complete_tx) = queued_message.write_complete_tx {
let _ = write_complete_tx.send(());
}
}
}
}
+2 -1
View File
@@ -22,6 +22,7 @@ use crate::message_processor::MessageProcessorArgs;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::QueuedOutgoingMessage;
use crate::transport::CHANNEL_CAPACITY;
use crate::transport::ConnectionState;
use crate::transport::OutboundConnectionState;
@@ -103,7 +104,7 @@ enum OutboundControlEvent {
/// Register a new writer for an opened connection.
Opened {
connection_id: ConnectionId,
writer: mpsc::Sender<crate::outgoing_message::OutgoingMessage>,
writer: mpsc::Sender<QueuedOutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
@@ -451,6 +451,10 @@ impl MessageProcessor {
self.codex_message_processor.drain_background_tasks().await;
}
pub(crate) async fn cancel_active_login(&self) {
self.codex_message_processor.cancel_active_login().await;
}
pub(crate) async fn clear_all_thread_listeners(&self) {
self.codex_message_processor
.clear_all_thread_listeners()
@@ -390,6 +390,7 @@ async fn read_response<T: serde::de::DeserializeOwned>(
let crate::outgoing_message::OutgoingEnvelope::ToConnection {
connection_id,
message,
..
} = envelope
else {
continue;
@@ -420,6 +421,7 @@ async fn read_thread_started_notification(
crate::outgoing_message::OutgoingEnvelope::ToConnection {
connection_id,
message,
..
} => {
if connection_id != TEST_CONNECTION_ID {
continue;
+89 -1
View File
@@ -81,17 +81,33 @@ impl RequestContext {
}
}
#[derive(Debug, Clone)]
#[derive(Debug)]
pub(crate) enum OutgoingEnvelope {
ToConnection {
connection_id: ConnectionId,
message: OutgoingMessage,
write_complete_tx: Option<oneshot::Sender<()>>,
},
Broadcast {
message: OutgoingMessage,
},
}
#[derive(Debug)]
pub(crate) struct QueuedOutgoingMessage {
pub(crate) message: OutgoingMessage,
pub(crate) write_complete_tx: Option<oneshot::Sender<()>>,
}
impl QueuedOutgoingMessage {
pub(crate) fn new(message: OutgoingMessage) -> Self {
Self {
message,
write_complete_tx: None,
}
}
}
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_server_request_id: AtomicI64,
@@ -299,6 +315,7 @@ impl OutgoingMessageSender {
.send(OutgoingEnvelope::ToConnection {
connection_id: *connection_id,
message: outgoing_message.clone(),
write_complete_tx: None,
})
.await
{
@@ -333,6 +350,7 @@ impl OutgoingMessageSender {
.send(OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Request(request),
write_complete_tx: None,
})
.await
{
@@ -519,6 +537,7 @@ impl OutgoingMessageSender {
.send(OutgoingEnvelope::ToConnection {
connection_id: *connection_id,
message: outgoing_message.clone(),
write_complete_tx: None,
})
.await
{
@@ -527,6 +546,28 @@ impl OutgoingMessageSender {
}
}
pub(crate) async fn send_server_notification_to_connection_and_wait(
&self,
connection_id: ConnectionId,
notification: ServerNotification,
) {
tracing::trace!("app-server event: {notification}");
let outgoing_message = OutgoingMessage::AppServerNotification(notification);
let (write_complete_tx, write_complete_rx) = oneshot::channel();
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id,
message: outgoing_message,
write_complete_tx: Some(write_complete_tx),
})
.await
{
warn!("failed to send server notification to client: {err:?}");
}
let _ = write_complete_rx.await;
}
pub(crate) async fn send_error(
&self,
request_id: ConnectionRequestId,
@@ -566,6 +607,7 @@ impl OutgoingMessageSender {
let send_fut = self.sender.send(OutgoingEnvelope::ToConnection {
connection_id,
message,
write_complete_tx: None,
});
let send_result = if let Some(request_context) = request_context {
send_fut.instrument(request_context.span()).await
@@ -818,6 +860,7 @@ mod tests {
OutgoingEnvelope::ToConnection {
connection_id,
message,
..
} => {
assert_eq!(connection_id, ConnectionId(42));
let OutgoingMessage::Response(response) = message else {
@@ -880,6 +923,7 @@ mod tests {
OutgoingEnvelope::ToConnection {
connection_id,
message,
..
} => {
assert_eq!(connection_id, ConnectionId(9));
let OutgoingMessage::Error(outgoing_error) = message else {
@@ -892,6 +936,50 @@ mod tests {
}
}
#[tokio::test]
async fn send_server_notification_to_connection_and_wait_tracks_write_completion() {
let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4);
let outgoing = OutgoingMessageSender::new(tx);
let send_task = tokio::spawn(async move {
outgoing
.send_server_notification_to_connection_and_wait(
ConnectionId(42),
ServerNotification::ModelRerouted(ModelReroutedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
from_model: "gpt-5.3-codex".to_string(),
to_model: "gpt-5.2".to_string(),
reason: ModelRerouteReason::HighRiskCyberActivity,
}),
)
.await
});
let envelope = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("should receive envelope before timeout")
.expect("channel should contain one message");
let OutgoingEnvelope::ToConnection {
connection_id,
message,
write_complete_tx,
} = envelope
else {
panic!("expected targeted server notification envelope");
};
assert_eq!(connection_id, ConnectionId(42));
assert!(matches!(message, OutgoingMessage::AppServerNotification(_)));
write_complete_tx
.expect("write completion sender should be attached")
.send(())
.expect("receiver should still be waiting");
timeout(Duration::from_secs(1), send_task)
.await
.expect("send task should finish after write completion is signaled")
.expect("send task should not panic");
}
#[tokio::test]
async fn connection_closed_clears_registered_request_contexts() {
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4);
+159 -43
View File
@@ -4,6 +4,7 @@ use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingError;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::QueuedOutgoingMessage;
use axum::Router;
use axum::body::Body;
use axum::extract::ConnectInfo;
@@ -187,7 +188,7 @@ impl FromStr for AppServerTransport {
pub(crate) enum TransportEvent {
ConnectionOpened {
connection_id: ConnectionId,
writer: mpsc::Sender<OutgoingMessage>,
writer: mpsc::Sender<QueuedOutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
},
ConnectionClosed {
@@ -225,13 +226,13 @@ pub(crate) struct OutboundConnectionState {
pub(crate) initialized: Arc<AtomicBool>,
pub(crate) experimental_api_enabled: Arc<AtomicBool>,
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) writer: mpsc::Sender<OutgoingMessage>,
pub(crate) writer: mpsc::Sender<QueuedOutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
}
impl OutboundConnectionState {
pub(crate) fn new(
writer: mpsc::Sender<OutgoingMessage>,
writer: mpsc::Sender<QueuedOutgoingMessage>,
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
@@ -262,7 +263,7 @@ pub(crate) async fn start_stdio_connection(
stdio_handles: &mut Vec<JoinHandle<()>>,
) -> IoResult<()> {
let connection_id = ConnectionId(0);
let (writer_tx, mut writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
let (writer_tx, mut writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(CHANNEL_CAPACITY);
let writer_tx_for_reader = writer_tx.clone();
transport_event_tx
.send(TransportEvent::ConnectionOpened {
@@ -309,8 +310,8 @@ pub(crate) async fn start_stdio_connection(
stdio_handles.push(tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(outgoing_message) = writer_rx.recv().await {
let Some(mut json) = serialize_outgoing_message(outgoing_message) else {
while let Some(queued_message) = writer_rx.recv().await {
let Some(mut json) = serialize_outgoing_message(queued_message.message) else {
continue;
};
json.push('\n');
@@ -318,6 +319,9 @@ pub(crate) async fn start_stdio_connection(
error!("Failed to write to stdout: {err}");
break;
}
if let Some(write_complete_tx) = queued_message.write_complete_tx {
let _ = write_complete_tx.send(());
}
}
info!("stdout writer exited (channel closed)");
}));
@@ -364,7 +368,7 @@ async fn run_websocket_connection(
websocket_stream: WebSocket,
transport_event_tx: mpsc::Sender<TransportEvent>,
) {
let (writer_tx, writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
let (writer_tx, writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(CHANNEL_CAPACITY);
let writer_tx_for_reader = writer_tx.clone();
let disconnect_token = CancellationToken::new();
if transport_event_tx
@@ -415,7 +419,7 @@ async fn run_websocket_connection(
async fn run_websocket_outbound_loop(
mut websocket_writer: futures::stream::SplitSink<WebSocket, WebSocketMessage>,
mut writer_rx: mpsc::Receiver<OutgoingMessage>,
mut writer_rx: mpsc::Receiver<QueuedOutgoingMessage>,
mut writer_control_rx: mpsc::Receiver<WebSocketMessage>,
disconnect_token: CancellationToken,
) {
@@ -432,16 +436,19 @@ async fn run_websocket_outbound_loop(
break;
}
}
outgoing_message = writer_rx.recv() => {
let Some(outgoing_message) = outgoing_message else {
queued_message = writer_rx.recv() => {
let Some(queued_message) = queued_message else {
break;
};
let Some(json) = serialize_outgoing_message(outgoing_message) else {
let Some(json) = serialize_outgoing_message(queued_message.message) else {
continue;
};
if websocket_writer.send(WebSocketMessage::Text(json.into())).await.is_err() {
break;
}
if let Some(write_complete_tx) = queued_message.write_complete_tx {
let _ = write_complete_tx.send(());
}
}
}
}
@@ -450,7 +457,7 @@ async fn run_websocket_outbound_loop(
async fn run_websocket_inbound_loop(
mut websocket_reader: futures::stream::SplitStream<WebSocket>,
transport_event_tx: mpsc::Sender<TransportEvent>,
writer_tx_for_reader: mpsc::Sender<OutgoingMessage>,
writer_tx_for_reader: mpsc::Sender<QueuedOutgoingMessage>,
writer_control_tx: mpsc::Sender<WebSocketMessage>,
connection_id: ConnectionId,
disconnect_token: CancellationToken,
@@ -501,7 +508,7 @@ async fn run_websocket_inbound_loop(
async fn forward_incoming_message(
transport_event_tx: &mpsc::Sender<TransportEvent>,
writer: &mpsc::Sender<OutgoingMessage>,
writer: &mpsc::Sender<QueuedOutgoingMessage>,
connection_id: ConnectionId,
payload: &str,
) -> bool {
@@ -518,7 +525,7 @@ async fn forward_incoming_message(
async fn enqueue_incoming_message(
transport_event_tx: &mpsc::Sender<TransportEvent>,
writer: &mpsc::Sender<OutgoingMessage>,
writer: &mpsc::Sender<QueuedOutgoingMessage>,
connection_id: ConnectionId,
message: JSONRPCMessage,
) -> bool {
@@ -541,7 +548,7 @@ async fn enqueue_incoming_message(
data: None,
},
});
match writer.try_send(overload_error) {
match writer.try_send(QueuedOutgoingMessage::new(overload_error)) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Closed(_)) => false,
Err(mpsc::error::TrySendError::Full(_overload_error)) => {
@@ -607,6 +614,7 @@ async fn send_message_to_connection(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
connection_id: ConnectionId,
message: OutgoingMessage,
write_complete_tx: Option<tokio::sync::oneshot::Sender<()>>,
) -> bool {
let Some(connection_state) = connections.get(&connection_id) else {
warn!("dropping message for disconnected connection: {connection_id:?}");
@@ -618,8 +626,12 @@ async fn send_message_to_connection(
}
let writer = connection_state.writer.clone();
let queued_message = QueuedOutgoingMessage {
message,
write_complete_tx,
};
if connection_state.can_disconnect() {
match writer.try_send(message) {
match writer.try_send(queued_message) {
Ok(()) => false,
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(
@@ -631,7 +643,7 @@ async fn send_message_to_connection(
disconnect_connection(connections, connection_id)
}
}
} else if writer.send(message).await.is_err() {
} else if writer.send(queued_message).await.is_err() {
disconnect_connection(connections, connection_id)
} else {
false
@@ -670,8 +682,11 @@ pub(crate) async fn route_outgoing_envelope(
OutgoingEnvelope::ToConnection {
connection_id,
message,
write_complete_tx,
} => {
let _ = send_message_to_connection(connections, connection_id, message).await;
let _ =
send_message_to_connection(connections, connection_id, message, write_complete_tx)
.await;
}
OutgoingEnvelope::Broadcast { message } => {
let target_connections: Vec<ConnectionId> = connections
@@ -688,8 +703,13 @@ pub(crate) async fn route_outgoing_envelope(
.collect();
for connection_id in target_connections {
let _ =
send_message_to_connection(connections, connection_id, message.clone()).await;
let _ = send_message_to_connection(
connections,
connection_id,
message.clone(),
/*write_complete_tx*/ None,
)
.await;
}
}
}
@@ -800,7 +820,8 @@ mod tests {
.recv()
.await
.expect("request should receive overload error");
let overload_json = serde_json::to_value(overload).expect("serialize overload error");
let overload_json =
serde_json::to_value(overload.message).expect("serialize overload error");
assert_eq!(
overload_json,
json!({
@@ -904,13 +925,15 @@ mod tests {
.expect("transport queue should accept first message");
writer_tx
.send(OutgoingMessage::AppServerNotification(
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "queued".to_string(),
details: None,
path: None,
range: None,
}),
.send(QueuedOutgoingMessage::new(
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "queued".to_string(),
details: None,
path: None,
range: None,
},
)),
))
.await
.expect("writer queue should accept first message");
@@ -934,7 +957,8 @@ mod tests {
.recv()
.await
.expect("writer queue should still contain original message");
let queued_json = serde_json::to_value(queued_outgoing).expect("serialize queued message");
let queued_json =
serde_json::to_value(queued_outgoing.message).expect("serialize queued message");
assert_eq!(
queued_json,
json!({
@@ -979,6 +1003,7 @@ mod tests {
range: None,
},
)),
write_complete_tx: None,
},
)
.await;
@@ -989,6 +1014,92 @@ mod tests {
);
}
#[tokio::test]
async fn to_connection_notifications_are_dropped_for_opted_out_clients() {
let connection_id = ConnectionId(10);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))),
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "task_started".to_string(),
details: None,
path: None,
range: None,
},
)),
write_complete_tx: None,
},
)
.await;
assert!(
writer_rx.try_recv().is_err(),
"opted-out notifications should not reach clients"
);
}
#[tokio::test]
async fn to_connection_notifications_are_preserved_for_non_opted_out_clients() {
let connection_id = ConnectionId(11);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "task_started".to_string(),
details: None,
path: None,
range: None,
},
)),
write_complete_tx: None,
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("notification should reach non-opted-out clients");
assert!(matches!(
message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "task_started"
));
}
#[tokio::test]
async fn command_execution_request_approval_strips_experimental_fields_without_capability() {
let connection_id = ConnectionId(8);
@@ -1042,6 +1153,7 @@ mod tests {
available_decisions: None,
},
}),
write_complete_tx: None,
},
)
.await;
@@ -1050,7 +1162,7 @@ mod tests {
.recv()
.await
.expect("request should be delivered to the connection");
let json = serde_json::to_value(message).expect("request should serialize");
let json = serde_json::to_value(message.message).expect("request should serialize");
assert_eq!(json["params"].get("additionalPermissions"), None);
assert_eq!(json["params"].get("skillMetadata"), None);
}
@@ -1108,6 +1220,7 @@ mod tests {
available_decisions: None,
},
}),
write_complete_tx: None,
},
)
.await;
@@ -1116,7 +1229,7 @@ mod tests {
.recv()
.await
.expect("request should be delivered to the connection");
let json = serde_json::to_value(message).expect("request should serialize");
let json = serde_json::to_value(message.message).expect("request should serialize");
let allowed_path = absolute_path("/tmp/allowed").to_string_lossy().into_owned();
assert_eq!(
json["params"]["additionalPermissions"],
@@ -1178,7 +1291,7 @@ mod tests {
}),
);
slow_writer_tx
.try_send(queued_message)
.try_send(QueuedOutgoingMessage::new(queued_message))
.expect("channel should have room");
let broadcast_message = OutgoingMessage::AppServerNotification(
@@ -1207,7 +1320,7 @@ mod tests {
.try_recv()
.expect("fast connection should receive the broadcast notification");
assert!(matches!(
fast_message,
fast_message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "test"
@@ -1217,7 +1330,7 @@ mod tests {
.try_recv()
.expect("slow connection should retain its original buffered message");
assert!(matches!(
slow_message,
slow_message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "already-buffered"
@@ -1229,13 +1342,15 @@ mod tests {
let connection_id = ConnectionId(3);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
writer_tx
.send(OutgoingMessage::AppServerNotification(
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "queued".to_string(),
details: None,
path: None,
range: None,
}),
.send(QueuedOutgoingMessage::new(
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "queued".to_string(),
details: None,
path: None,
range: None,
},
)),
))
.await
.expect("channel should accept the first queued message");
@@ -1265,6 +1380,7 @@ mod tests {
range: None,
}),
),
write_complete_tx: None,
},
)
.await
@@ -1280,7 +1396,7 @@ mod tests {
.expect("routing task should succeed");
assert!(matches!(
first,
first.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "queued"
@@ -1289,7 +1405,7 @@ mod tests {
.try_recv()
.expect("second notification should be delivered once the queue has room");
assert!(matches!(
second,
second.message,
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification { summary, .. }
)) if summary == "second"
+293
View File
@@ -1,6 +1,10 @@
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use chrono::Duration;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
@@ -8,10 +12,17 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
@@ -207,6 +218,288 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(true),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let status: GetAuthStatusResponse = to_response(resp)?;
assert_eq!(
status,
GetAuthStatusResponse {
auth_method: Some(AuthMode::ApiKey),
auth_token: Some("sk-test-key".to_string()),
requires_openai_auth: Some(true),
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("stale-access-token")
.refresh_token("stale-refresh-token")
.account_id("acct_123")
.email("user@example.com")
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"code": "refresh_token_reused"
}
})))
.expect(1)
.mount(&server)
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(true),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let status: GetAuthStatusResponse = to_response(resp)?;
assert_eq!(
status,
GetAuthStatusResponse {
auth_method: Some(AuthMode::Chatgpt),
auth_token: None,
requires_openai_auth: Some(true),
}
);
let second_request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(true),
})
.await?;
let second_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)),
)
.await??;
let second_status: GetAuthStatusResponse = to_response(second_resp)?;
assert_eq!(second_status, status);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("stale-access-token")
.refresh_token("stale-refresh-token")
.account_id("acct_123")
.email("user@example.com")
.plan_type("pro")
.last_refresh(Some(Utc::now() - Duration::days(9))),
AuthCredentialsStoreMode::File,
)?;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"code": "refresh_token_reused"
}
})))
.expect(2)
.mount(&server)
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let status: GetAuthStatusResponse = to_response(resp)?;
assert_eq!(
status,
GetAuthStatusResponse {
auth_method: Some(AuthMode::Chatgpt),
auth_token: None,
requires_openai_auth: Some(true),
}
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("stale-access-token")
.refresh_token("stale-refresh-token")
.account_id("acct_123")
.email("user@example.com")
.plan_type("pro")
.last_refresh(Some(Utc::now() - Duration::days(9))),
AuthCredentialsStoreMode::File,
)?;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"code": "refresh_token_reused"
}
})))
.expect(2)
.mount(&server)
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let failed_request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(true),
})
.await?;
let failed_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(failed_request_id)),
)
.await??;
let failed_status: GetAuthStatusResponse = to_response(failed_resp)?;
assert_eq!(
failed_status,
GetAuthStatusResponse {
auth_method: Some(AuthMode::Chatgpt),
auth_token: None,
requires_openai_auth: Some(true),
}
);
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("recovered-access-token")
.refresh_token("recovered-refresh-token")
.account_id("acct_123")
.email("user@example.com")
.plan_type("pro")
.last_refresh(Some(Utc::now())),
AuthCredentialsStoreMode::File,
)?;
let recovered_request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await?;
let recovered_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(recovered_request_id)),
)
.await??;
let recovered_status: GetAuthStatusResponse = to_response(recovered_resp)?;
assert_eq!(
recovered_status,
GetAuthStatusResponse {
auth_method: Some(AuthMode::Chatgpt),
auth_token: Some("recovered-access-token".to_string()),
requires_openai_auth: Some(true),
}
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> {
let codex_home = TempDir::new()?;
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env dotslash
// This is an instance of the fork of Bash that we bundle with
// https://www.npmjs.com/package/@openai/codex-shell-tool-mcp.
// Fetching the prebuilt version via DotSlash makes it easier to write
// integration tests for shell execution flows.
//
// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for
// multiple platforms, but we could save a bit of space by making arch-specific
// artifacts available in the GitHub releases and referencing those here.
{
"name": "codex-bash",
"platforms": {
// macOS 13 builds (and therefore x86_64) were dropped in
// https://github.com/openai/codex/pull/7295, so we only provide an
// Apple Silicon build for now.
"macos-aarch64": {
"size": 37003612,
"hash": "blake3",
"digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb",
"format": "tar.gz",
"path": "package/vendor/aarch64-apple-darwin/bash/macos-15/bash",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz"
},
{
"type": "github-release",
"repo": "openai/codex",
"tag": "rust-v0.65.0",
"name": "codex-shell-tool-mcp-npm-0.65.0.tgz"
}
]
},
// Note the `musl` parts of the Linux paths are misleading: the Bash
// binaries are actually linked against `glibc`, but the
// `codex-execve-wrapper` that invokes them is linked against `musl`.
"linux-x86_64": {
"size": 37003612,
"hash": "blake3",
"digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb",
"format": "tar.gz",
"path": "package/vendor/x86_64-unknown-linux-musl/bash/ubuntu-24.04/bash",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz"
},
{
"type": "github-release",
"repo": "openai/codex",
"tag": "rust-v0.65.0",
"name": "codex-shell-tool-mcp-npm-0.65.0.tgz"
}
]
},
"linux-aarch64": {
"size": 37003612,
"hash": "blake3",
"digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb",
"format": "tar.gz",
"path": "package/vendor/aarch64-unknown-linux-musl/bash/ubuntu-24.04/bash",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz"
},
{
"type": "github-release",
"repo": "openai/codex",
"tag": "rust-v0.65.0",
"name": "codex-shell-tool-mcp-npm-0.65.0.tgz"
}
]
},
}
}
+141 -12
View File
@@ -11,6 +11,9 @@ use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::RequestId;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
@@ -41,16 +44,15 @@ plugins = true
}
#[tokio::test]
async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> {
async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
"{not json",
)?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
std::fs::write(marketplace_path.as_path(), "{not json")?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
@@ -78,15 +80,24 @@ async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> {
let response: PluginListResponse = to_response(response)?;
assert!(
response.marketplaces.iter().all(|marketplace| {
marketplace.path
!= AbsolutePathBuf::try_from(
repo_root.path().join(".agents/plugins/marketplace.json"),
)
.expect("absolute marketplace path")
}),
response
.marketplaces
.iter()
.all(|marketplace| { marketplace.path != marketplace_path }),
"invalid marketplace should be skipped"
);
assert_eq!(response.marketplace_load_errors.len(), 1);
assert_eq!(
response.marketplace_load_errors[0].marketplace_path,
marketplace_path
);
assert!(
response.marketplace_load_errors[0]
.message
.contains("invalid marketplace file"),
"unexpected error: {:?}",
response.marketplace_load_errors
);
Ok(())
}
@@ -116,6 +127,124 @@ async fn plugin_list_rejects_relative_cwds() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load() -> Result<()>
{
let codex_home = TempDir::new()?;
let valid_repo_root = TempDir::new()?;
let invalid_repo_root = TempDir::new()?;
std::fs::create_dir_all(valid_repo_root.path().join(".git"))?;
std::fs::create_dir_all(valid_repo_root.path().join(".agents/plugins"))?;
std::fs::create_dir_all(
valid_repo_root
.path()
.join("plugins/valid-plugin/.codex-plugin"),
)?;
std::fs::create_dir_all(invalid_repo_root.path().join(".git"))?;
std::fs::create_dir_all(invalid_repo_root.path().join(".agents/plugins"))?;
write_plugins_enabled_config(codex_home.path())?;
let valid_marketplace_path = AbsolutePathBuf::try_from(
valid_repo_root
.path()
.join(".agents/plugins/marketplace.json"),
)?;
let invalid_marketplace_path = AbsolutePathBuf::try_from(
invalid_repo_root
.path()
.join(".agents/plugins/marketplace.json"),
)?;
let valid_plugin_path =
AbsolutePathBuf::try_from(valid_repo_root.path().join("plugins/valid-plugin"))?;
std::fs::write(
valid_marketplace_path.as_path(),
r#"{
"name": "valid-marketplace",
"plugins": [
{
"name": "valid-plugin",
"source": {
"source": "local",
"path": "./plugins/valid-plugin"
}
}
]
}"#,
)?;
std::fs::write(
valid_repo_root
.path()
.join("plugins/valid-plugin/.codex-plugin/plugin.json"),
r#"{"name":"valid-plugin"}"#,
)?;
std::fs::write(invalid_marketplace_path.as_path(), "{not json")?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![
AbsolutePathBuf::try_from(valid_repo_root.path())?,
AbsolutePathBuf::try_from(invalid_repo_root.path())?,
]),
force_remote_sync: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(
response.marketplaces,
vec![PluginMarketplaceEntry {
name: "valid-marketplace".to_string(),
path: valid_marketplace_path,
interface: None,
plugins: vec![PluginSummary {
id: "valid-plugin@valid-marketplace".to_string(),
name: "valid-plugin".to_string(),
source: PluginSource::Local {
path: valid_plugin_path,
},
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
interface: None,
}],
}]
);
assert_eq!(response.marketplace_load_errors.len(), 1);
assert_eq!(
response.marketplace_load_errors[0].marketplace_path,
invalid_marketplace_path
);
assert!(
response.marketplace_load_errors[0]
.message
.contains("invalid marketplace file"),
"unexpected error: {:?}",
response.marketplace_load_errors
);
assert_eq!(response.remote_sync_error, None);
assert!(response.featured_plugin_ids.is_empty());
Ok(())
}
#[tokio::test]
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -23,6 +23,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::RolloutItem;
@@ -959,7 +960,7 @@ async fn thread_list_includes_git_info() -> Result<()> {
create_minimal_config(codex_home.path())?;
let git_info = CoreGitInfo {
commit_hash: Some("abc123".to_string()),
commit_hash: Some(GitSha::new("abc123")),
branch: Some("main".to_string()),
repository_url: Some("https://example.com/repo.git".to_string()),
};
@@ -20,6 +20,7 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_core::state_db::reconcile_rollout;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as RolloutGitInfo;
use codex_state::StateRuntime;
@@ -378,7 +379,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> {
"Thread preview",
Some("mock_provider"),
Some(RolloutGitInfo {
commit_hash: Some("abc123".to_string()),
commit_hash: Some(GitSha::new("abc123")),
branch: Some("feature/sidebar-pr".to_string()),
repository_url: Some("git@example.com:openai/codex.git".to_string()),
}),
+6 -5
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env dotslash
// This is the patched zsh fork built by
// `.github/workflows/shell-tool-mcp.yml` for the shell-tool-mcp package.
// This is the patched zsh fork corresponding to
// `codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch`.
// Fetching the prebuilt version via DotSlash makes it easier to write
// integration tests that exercise the zsh fork behavior in app-server tests.
//
// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for
// multiple platforms, but we could save a bit of space by making arch-specific
// artifacts available in the GitHub releases and referencing those here.
// This checked-in fixture is still pinned to the latest released bundle that
// contains this binary. New releases publish standalone `codex-zsh-*.tar.gz`
// assets plus a generated `codex-zsh` DotSlash release asset, so this file can
// be retargeted when a newer fork build needs to be exercised in tests.
{
"name": "codex-zsh",
"platforms": {
+1 -1
View File
@@ -17,7 +17,7 @@ codex-utils-cargo-bin = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
codex-git = { workspace = true }
codex-git-utils = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+4 -2
View File
@@ -2,6 +2,8 @@ use std::path::PathBuf;
use clap::Parser;
use codex_core::config::Config;
use codex_git_utils::ApplyGitRequest;
use codex_git_utils::apply_git_patch;
use codex_utils_cli::CliConfigOverrides;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
@@ -57,13 +59,13 @@ pub async fn apply_diff_from_task(
async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> anyhow::Result<()> {
let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()));
let req = codex_git::ApplyGitRequest {
let req = ApplyGitRequest {
cwd,
diff: diff.to_string(),
revert: false,
preflight: false,
};
let res = codex_git::apply_git_patch(&req)?;
let res = apply_git_patch(&req)?;
if res.exit_code != 0 {
anyhow::bail!(
"Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}",
+1 -1
View File
@@ -25,4 +25,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2.0.17"
codex-backend-client = { path = "../backend-client", optional = true }
codex-git = { workspace = true }
codex-git-utils = { workspace = true }
+4 -2
View File
@@ -16,6 +16,8 @@ use chrono::Utc;
use codex_backend_client as backend;
use codex_backend_client::CodeTaskDetailsResponseExt;
use codex_git_utils::ApplyGitRequest;
use codex_git_utils::apply_git_patch;
#[derive(Clone)]
pub struct HttpClient {
@@ -459,13 +461,13 @@ mod api {
});
}
let req = codex_git::ApplyGitRequest {
let req = ApplyGitRequest {
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
diff: diff.clone(),
revert: false,
preflight,
};
let r = codex_git::apply_git_patch(&req)
let r = apply_git_patch(&req)
.map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?;
let status = if r.exit_code == 0 {
+1 -1
View File
@@ -27,4 +27,4 @@ pub use mock::MockClient;
#[cfg(feature = "online")]
pub use http::HttpClient;
// Reusable apply engine now lives in the shared crate `codex-git`.
// Reusable apply engine now lives in the shared crate `codex-git-utils`.
+1
View File
@@ -22,6 +22,7 @@ codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [
] }
codex-client = { workspace = true }
codex-core = { path = "../core" }
codex-git-utils = { workspace = true }
codex-login = { path = "../login" }
codex-tui = { path = "../tui" }
codex-utils-cli = { workspace = true }
+4 -2
View File
@@ -10,6 +10,8 @@ pub use cli::Cli;
use anyhow::anyhow;
use chrono::Utc;
use codex_cloud_tasks_client::TaskStatus;
use codex_git_utils::current_branch_name;
use codex_git_utils::default_branch_name;
use owo_colors::OwoColorize;
use owo_colors::Stream;
use std::cmp::Ordering;
@@ -119,11 +121,11 @@ struct RealGitInfo;
#[async_trait::async_trait]
impl GitInfoProvider for RealGitInfo {
async fn default_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::default_branch_name(path).await
default_branch_name(path).await
}
async fn current_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::current_branch_name(path).await
current_branch_name(path).await
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ codex-shell-command = { workspace = true }
codex-skills = { workspace = true }
codex-execpolicy = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
+17 -23
View File
@@ -812,35 +812,32 @@ impl AgentControl {
return;
};
let child_thread = state.get_thread(child_thread_id).await.ok();
if let Some(child_agent_path) = child_agent_path
let message = format_subagent_notification_message(child_reference.as_str(), &status);
if child_agent_path.is_some()
&& child_thread
.as_ref()
.map(|thread| thread.enabled(Feature::MultiAgentV2))
.unwrap_or(true)
{
let AgentStatus::Completed(Some(content)) = &status else {
let Some(child_agent_path) = child_agent_path.clone() else {
return;
};
let Some((parent_path, _)) = child_agent_path.as_str().rsplit_once('/') else {
return;
};
let Ok(parent_agent_path) = AgentPath::try_from(parent_path) else {
return;
};
let Some(parent_thread_id) = control.state.agent_id_for_path(&parent_agent_path)
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| AgentPath::try_from(parent).ok())
else {
return;
};
let communication = InterAgentCommunication::new(
child_agent_path,
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
let _ = control
.send_inter_agent_communication(
parent_thread_id,
InterAgentCommunication::new(
child_agent_path,
parent_agent_path,
Vec::new(),
content.clone(),
),
)
.send_inter_agent_communication(parent_thread_id, communication)
.await;
return;
}
@@ -848,10 +845,7 @@ impl AgentControl {
return;
};
parent_thread
.inject_user_message_without_turn(format_subagent_notification_message(
child_reference.as_str(),
&status,
))
.inject_user_message_without_turn(message)
.await;
});
}
@@ -1111,7 +1105,7 @@ fn last_task_message_from_item(item: &ResponseItem) -> Option<String> {
}
match item {
ResponseItem::Message { role, content, .. } if role == "user" => {
ResponseItem::Message { role, .. } if role == "user" => {
let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else {
return None;
};
+159 -90
View File
@@ -29,8 +29,6 @@ use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::sleep;
use tokio::time::timeout;
const MULTI_AGENT_EVENTUAL_TIMEOUT: Duration = Duration::from_secs(5);
use toml::Value as TomlValue;
async fn test_config_with_cli_overrides(
@@ -409,6 +407,67 @@ async fn send_input_submits_user_message() {
assert_eq!(captured, Some(expected));
}
#[tokio::test]
async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() {
let harness = AgentControlHarness::new().await;
let (thread_id, thread) = harness.start_thread().await;
let communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"hello from tests".to_string(),
false,
);
let submission_id = harness
.control
.send_inter_agent_communication(thread_id, communication.clone())
.await
.expect("send_inter_agent_communication should succeed");
assert!(!submission_id.is_empty());
let expected = (
thread_id,
Op::InterAgentCommunication {
communication: communication.clone(),
},
);
let captured = harness
.manager
.captured_ops()
.into_iter()
.find(|entry| *entry == expected);
assert_eq!(captured, Some(expected));
timeout(Duration::from_secs(5), async {
loop {
if thread
.codex
.session
.has_queued_response_items_for_next_turn()
.await
{
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("inter-agent communication should stay pending");
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(!history_contains_assistant_inter_agent_communication(
&history_items,
&communication
));
}
#[tokio::test]
async fn append_message_records_assistant_message() {
let harness = AgentControlHarness::new().await;
@@ -964,94 +1023,6 @@ async fn spawn_child_completion_notifies_parent_history() {
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
}
#[tokio::test]
async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() {
let harness = AgentControlHarness::new().await;
let (root_thread_id, _) = harness.start_thread().await;
let mut config = harness.config.clone();
let _ = config.features.enable(Feature::MultiAgentV2);
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let worker_thread_id = harness
.control
.spawn_agent(
config.clone(),
text_input("hello worker"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root_thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("worker spawn should succeed");
let tester_path = worker_path.join("tester").expect("tester path");
let tester_thread_id = harness
.control
.spawn_agent(
config,
text_input("hello tester"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("tester spawn should succeed");
let tester_thread = harness
.manager
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let tester_turn = tester_thread.codex.session.new_default_turn().await;
tester_thread
.codex
.session
.send_event(
tester_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
}),
)
.await;
let expected = InterAgentCommunication::new(
tester_path.clone(),
worker_path.clone(),
Vec::new(),
"done".to_string(),
);
timeout(MULTI_AGENT_EVENTUAL_TIMEOUT, async {
loop {
let delivered = harness
.manager
.captured_ops()
.into_iter()
.any(|(thread_id, op)| {
thread_id == worker_thread_id
&& matches!(
op,
Op::InterAgentCommunication { communication }
if communication == expected
)
});
if delivered {
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("completion watcher should send inter-agent communication");
}
#[tokio::test]
async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
let harness = AgentControlHarness::new().await;
@@ -1147,11 +1118,109 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
AgentPath::root(),
Vec::new(),
"done".to_string(),
true,
)
));
assert!(!has_subagent_notification(&root_history_items));
}
#[tokio::test]
async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
let harness = AgentControlHarness::new().await;
let (_root_thread_id, root_thread) = harness.start_thread().await;
let (worker_thread_id, _worker_thread) = harness.start_thread().await;
let mut tester_config = harness.config.clone();
let _ = tester_config.features.enable(Feature::MultiAgentV2);
let tester_thread_id = harness
.manager
.start_thread(tester_config)
.await
.expect("tester thread should start")
.thread_id;
let tester_thread = harness
.manager
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let tester_path = worker_path.join("tester").expect("tester path");
harness.control.maybe_start_completion_watcher(
tester_thread_id,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
tester_path.to_string(),
Some(tester_path.clone()),
);
let tester_turn = tester_thread.codex.session.new_default_turn().await;
tester_thread
.codex
.session
.send_event(
tester_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
last_agent_message: Some("done".to_string()),
}),
)
.await;
let expected_message = crate::session_prefix::format_subagent_notification_message(
tester_path.as_str(),
&AgentStatus::Completed(Some("done".to_string())),
);
let expected = (
worker_thread_id,
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
tester_path.clone(),
worker_path.clone(),
Vec::new(),
expected_message.clone(),
false,
),
},
);
timeout(Duration::from_secs(5), async {
loop {
let captured = harness
.manager
.captured_ops()
.into_iter()
.find(|entry| *entry == expected);
if captured == Some(expected.clone()) {
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("completion watcher should queue a direct-parent message");
let root_history_items = root_thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(!history_contains_assistant_inter_agent_communication(
&root_history_items,
&InterAgentCommunication::new(
tester_path,
AgentPath::root(),
Vec::new(),
expected_message,
false,
)
));
}
#[tokio::test]
async fn completion_watcher_notifies_parent_when_child_is_missing() {
let harness = AgentControlHarness::new().await;
+2 -2
View File
@@ -1,9 +1,9 @@
use crate::AuthManager;
use crate::config::Config;
use crate::default_client::create_client;
use crate::git_info::collect_git_info;
use crate::git_info::get_git_repo_root;
use crate::plugins::PluginTelemetryMetadata;
use codex_git_utils::collect_git_info;
use codex_git_utils::get_git_repo_root;
use codex_protocol::protocol::SkillScope;
use serde::Serialize;
use sha1::Digest;
+64 -2
View File
@@ -1,10 +1,72 @@
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_app_server_protocol::AppInfo;
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
pub(crate) fn render_apps_section() -> String {
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
if !connectors
.iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
{
return None;
}
let body = format!(
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
);
format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}")
Some(format!(
"{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}"
))
}
#[cfg(test)]
mod tests {
use super::*;
fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible,
is_enabled,
plugin_display_names: Vec::new(),
}
}
#[test]
fn omits_apps_section_without_accessible_and_enabled_apps() {
assert_eq!(render_apps_section(&[]), None);
assert_eq!(
render_apps_section(&[connector(
"calendar", /*is_accessible*/ true, /*is_enabled*/ false
)]),
None
);
assert_eq!(
render_apps_section(&[connector(
"calendar", /*is_accessible*/ false, /*is_enabled*/ true
)]),
None
);
}
#[test]
fn renders_apps_section_with_an_accessible_and_enabled_app() {
let rendered = render_apps_section(&[connector(
"calendar", /*is_accessible*/ true, /*is_enabled*/ true,
)])
.expect("expected apps section");
assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG));
assert!(rendered.contains("## Apps (Connectors)"));
assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG));
}
}
+32 -23
View File
@@ -235,9 +235,6 @@ pub(crate) struct PreviousTurnSettings {
use crate::exec_policy::ExecPolicyUpdateError;
use crate::feedback_tags;
use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherEvent;
use crate::git_info::get_git_repo_root;
use crate::guardian::GuardianReviewSessionManager;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::inspect_pending_input;
@@ -322,6 +319,8 @@ use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
use crate::skills::injection::tool_kind_for_path;
use crate::skills::resolve_skill_dependencies_for_turn;
use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
use crate::state::SessionState;
@@ -350,6 +349,7 @@ use crate::unified_exec::UnifiedExecProcessManager;
use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_git_utils::get_git_repo_root;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_otel::metrics::names::THREAD_STARTED_METRIC;
@@ -403,7 +403,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) file_watcher: Arc<FileWatcher>,
pub(crate) skills_watcher: Arc<SkillsWatcher>,
pub(crate) conversation_history: InitialHistory,
pub(crate) session_source: SessionSource,
pub(crate) agent_control: AgentControl,
@@ -456,7 +456,7 @@ impl Codex {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
conversation_history,
session_source,
agent_control,
@@ -644,7 +644,7 @@ impl Codex {
skills_manager,
plugins_manager,
mcp_manager.clone(),
file_watcher,
skills_watcher,
agent_control,
)
.await
@@ -1297,13 +1297,13 @@ impl Session {
self.out_of_band_elicitation_paused.send_replace(paused);
}
fn start_file_watcher_listener(self: &Arc<Self>) {
let mut rx = self.services.file_watcher.subscribe();
fn start_skills_watcher_listener(self: &Arc<Self>) {
let mut rx = self.services.skills_watcher.subscribe();
let weak_sess = Arc::downgrade(self);
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(FileWatcherEvent::SkillsChanged { .. }) => {
Ok(SkillsWatcherEvent::SkillsChanged { .. }) => {
let Some(sess) = weak_sess.upgrade() else {
break;
};
@@ -1439,7 +1439,7 @@ impl Session {
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
skills_watcher: Arc<SkillsWatcher>,
agent_control: AgentControl,
) -> anyhow::Result<Arc<Self>> {
debug!(
@@ -1858,7 +1858,7 @@ impl Session {
skills_manager,
plugins_manager: Arc::clone(&plugins_manager),
mcp_manager: Arc::clone(&mcp_manager),
file_watcher,
skills_watcher,
agent_control,
network_proxy,
network_approval: Arc::clone(&network_approval),
@@ -1937,7 +1937,7 @@ impl Session {
}
// Start the watcher after SessionConfigured so it cannot emit earlier events.
sess.start_file_watcher_listener();
sess.start_skills_watcher_listener();
// Construct sandbox_state before MCP startup so it can be sent to each
// MCP server immediately after it becomes ready (avoiding blocking).
let sandbox_state = SandboxState {
@@ -3537,7 +3537,16 @@ impl Session {
}
}
if turn_context.apps_enabled() {
developer_sections.push(render_apps_section());
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
let accessible_and_enabled_connectors =
connectors::list_accessible_and_enabled_connectors_from_manager(
&mcp_connection_manager,
&turn_context.config,
)
.await;
if let Some(apps_section) = render_apps_section(&accessible_and_enabled_connectors) {
developer_sections.push(apps_section);
}
}
let implicit_skills = turn_context
.turn_skills
@@ -4650,27 +4659,27 @@ mod handlers {
}
}
/// Records an inter-agent assistant envelope and, when requested, wakes the recipient by
/// starting a regular turn if the session is currently idle.
pub async fn inter_agent_communication(
sess: &Arc<Session>,
sub_id: String,
communication: InterAgentCommunication,
) {
let pending_item = communication.to_response_input_item();
if sess
.inject_response_items(vec![pending_item.clone()])
.await
.is_ok()
{
if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await {
return;
}
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
sess.queue_response_items_for_next_turn(vec![pending_item])
.await;
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
.await;
if communication.trigger_turn {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new())
.await;
}
}
pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command: String) {
@@ -41,6 +41,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem {
AgentPath::root().join("worker").unwrap(),
Vec::new(),
text.to_string(),
true,
);
ResponseItem::Message {
id: None,
+1 -1
View File
@@ -80,7 +80,7 @@ pub(crate) async fn run_codex_thread_interactive(
skills_manager: Arc::clone(&parent_session.services.skills_manager),
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
file_watcher: Arc::clone(&parent_session.services.file_watcher),
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
conversation_history: initial_history.unwrap_or(InitialHistory::New),
session_source: SessionSource::SubAgent(subagent_source),
agent_control: parent_session.services.agent_control.clone(),
+35 -5
View File
@@ -38,6 +38,7 @@ use crate::protocol::NetworkApprovalProtocol;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::RateLimitWindow;
use crate::protocol::ResumedHistory;
use crate::protocol::RolloutItem;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
@@ -1166,6 +1167,35 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
})
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// The parent rollout writer drains asynchronously after turn completion.
// Wait until the persisted JSONL includes the source user turn before forking from it.
let mut source_history_persisted = false;
for _ in 0..100 {
let history = RolloutRecorder::get_rollout_history(&rollout_path).await;
source_history_persisted = history.ok().is_some_and(|history| {
history.get_rollout_items().into_iter().any(|item| {
matches!(
item,
RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. })
if role == "user"
&& content.iter().any(|content_item| {
matches!(
content_item,
ContentItem::InputText { text } if text == "fork seed"
)
})
)
})
});
if source_history_persisted {
break;
}
sleep(StdDuration::from_millis(10)).await;
}
assert!(
source_history_persisted,
"source rollout should contain the completed pre-fork user turn before forking"
);
let mut fork_config = initial.config.clone();
fork_config.permissions.approval_policy =
@@ -2494,7 +2524,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(FileWatcher::noop()),
Arc::new(SkillsWatcher::noop()),
AgentControl::default(),
)
.await;
@@ -2593,7 +2623,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
.expect("create environment"),
);
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = Arc::new(SkillsWatcher::noop());
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
McpConnectionManager::new_mcp_connection_manager_for_tests(
@@ -2627,7 +2657,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
@@ -3428,7 +3458,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
.expect("create environment"),
);
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = Arc::new(SkillsWatcher::noop());
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
McpConnectionManager::new_mcp_connection_manager_for_tests(
@@ -3462,7 +3492,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
+2 -2
View File
@@ -435,7 +435,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
true,
));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = Arc::new(SkillsWatcher::noop());
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
config,
@@ -444,7 +444,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
conversation_history: InitialHistory::New,
session_source: SessionSource::SubAgent(SubAgentSource::Other(
GUARDIAN_REVIEWER_NAME.to_string(),
+2 -2
View File
@@ -39,7 +39,6 @@ use crate::config_loader::McpServerRequirement;
use crate::config_loader::ResidencyRequirement;
use crate::config_loader::Sourced;
use crate::config_loader::load_config_layers_state;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::memories::memory_root;
use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
@@ -66,6 +65,7 @@ use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::Features;
use codex_features::FeaturesToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
@@ -134,7 +134,7 @@ pub use service::ConfigService;
pub use service::ConfigServiceError;
pub use types::ApprovalsReviewer;
pub use codex_git::GhostSnapshotConfig;
pub use codex_git_utils::GhostSnapshotConfig;
/// Maximum number of bytes of the documentation that will be embedded. Larger
/// files are *silently truncated* to this size so we do not take up too much of
+1 -1
View File
@@ -7,10 +7,10 @@ mod tests;
use crate::config::ConfigToml;
use crate::config_loader::layer_io::LoadedConfigLayers;
use crate::git_info::resolve_root_git_project_for_trust;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigRequirementsWithSources;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::protocol::AskForApproval;
+13
View File
@@ -103,6 +103,19 @@ pub async fn list_accessible_connectors_from_mcp_tools(
)
}
pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
mcp_connection_manager: &McpConnectionManager,
config: &Config,
) -> Vec<AppInfo> {
with_app_enabled_state(
accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await),
config,
)
.into_iter()
.filter(|connector| connector.is_accessible && connector.is_enabled)
.collect()
}
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
+64 -4
View File
@@ -1,5 +1,7 @@
use crate::codex::TurnContext;
use crate::context_manager::normalize;
use crate::event_mapping::has_non_contextual_dev_message_content;
use crate::event_mapping::is_contextual_dev_message_content;
use crate::event_mapping::is_contextual_user_message_content;
use crate::truncate::TruncationPolicy;
use crate::truncate::approx_bytes_for_tokens;
@@ -40,7 +42,9 @@ pub(crate) struct ContextManager {
/// match the current turn after context updates are persisted.
///
/// When this is `None`, settings diffing treats the next turn as having no
/// baseline and emits a full reinjection of context state.
/// baseline and emits a full reinjection of context state. Rollback may
/// also clear this when it trims a mixed initial-context developer bundle
/// whose non-diff fragments no longer exist in the surviving history.
reference_context_item: Option<TurnContextItem>,
}
@@ -215,6 +219,12 @@ impl ContextManager {
/// - if there are no user turns, this is a no-op
/// - if `num_turns` exceeds the number of user turns, all user turns are dropped while
/// preserving any items that occurred before the first user message.
///
/// If rollback trims a pre-turn developer message that mixes contextual fragments with
/// persistent developer text from `build_initial_context`, this also clears
/// `reference_context_item`. The surviving history no longer contains the full bundle that
/// established the prior baseline, so future turns must fall back to full reinjection instead
/// of diffing against stale state.
pub(crate) fn drop_last_n_user_turns(&mut self, num_turns: u32) {
if num_turns == 0 {
return;
@@ -222,18 +232,21 @@ impl ContextManager {
let snapshot = self.items.clone();
let user_positions = user_message_positions(&snapshot);
let Some(&first_user_idx) = user_positions.first() else {
let Some(&first_instruction_turn_idx) = user_positions.first() else {
self.replace(snapshot);
return;
};
let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
let cut_idx = if n_from_end >= user_positions.len() {
first_user_idx
let mut cut_idx = if n_from_end >= user_positions.len() {
first_instruction_turn_idx
} else {
user_positions[user_positions.len() - n_from_end]
};
cut_idx =
self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx);
self.replace(snapshot[..cut_idx].to_vec());
}
@@ -382,6 +395,53 @@ impl ContextManager {
| ResponseItem::Other => item.clone(),
}
}
/// Walk backward from a rollback cut and trim contiguous pre-turn context-update items.
///
/// Returns the adjusted cut index after removing contextual developer/user items immediately
/// above the rolled-back turn boundary.
///
/// `first_instruction_turn_idx` is the earliest rollback-eligible instruction-turn boundary
/// in `snapshot`; the trim walk never crosses it so any session-prefix items that predate the
/// first real turn survive rollback.
///
/// `cut_idx` is the tentative slice boundary after dropping the requested number of
/// instruction turns, before stripping contextual pre-turn items that sit immediately above
/// that boundary.
///
/// If any trimmed developer message was a mixed `build_initial_context` bundle containing both
/// rollback-trimmable contextual fragments and persistent developer text, this also clears the
/// stored `reference_context_item` baseline so the next real turn falls back to full
/// reinjection.
fn trim_pre_turn_context_updates(
&mut self,
snapshot: &[ResponseItem],
first_instruction_turn_idx: usize,
mut cut_idx: usize,
) -> usize {
while cut_idx > first_instruction_turn_idx {
match &snapshot[cut_idx - 1] {
ResponseItem::Message { role, content, .. }
if role == "developer" && is_contextual_dev_message_content(content) =>
{
if has_non_contextual_dev_message_content(content) {
// Mixed `build_initial_context` bundles are not reconstructible from
// steady-state diffs once trimmed, so the next real turn must fully
// reinject context instead of diffing against a stale baseline.
self.reference_context_item = None;
}
cut_idx -= 1;
}
ResponseItem::Message { role, content, .. }
if role == "user" && is_contextual_user_message_content(content) =>
{
cut_idx -= 1;
}
_ => break,
}
}
cut_idx
}
}
fn truncate_function_output_payload(
@@ -3,8 +3,9 @@ use crate::truncate;
use crate::truncate::TruncationPolicy;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_git::GhostCommit;
use codex_git_utils::GhostCommit;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
@@ -18,12 +19,16 @@ use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnContextItem;
use image::ImageBuffer;
use image::ImageFormat;
use image::Rgba;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use std::path::PathBuf;
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
@@ -46,6 +51,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
AgentPath::root().join("worker").unwrap(),
Vec::new(),
text.to_string(),
true,
);
ResponseItem::Message {
id: None,
@@ -90,6 +96,56 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
}
}
fn developer_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: texts
.iter()
.map(|text| ContentItem::InputText {
text: (*text).to_string(),
})
.collect(),
end_turn: None,
phase: None,
}
}
fn reference_context_item() -> TurnContextItem {
TurnContextItem {
turn_id: Some("reference-turn".to_string()),
trace_id: None,
cwd: PathBuf::from("/tmp/reference-cwd"),
current_date: Some("2026-03-23".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
model: "gpt-test".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: Some(false),
effort: None,
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)),
}
}
fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem {
ResponseItem::CustomToolCallOutput {
call_id: call_id.to_string(),
@@ -252,6 +308,15 @@ fn inter_agent_assistant_messages_are_turn_boundaries() {
assert!(is_user_turn_boundary(&item));
}
#[test]
fn for_prompt_preserves_inter_agent_assistant_messages() {
let item = inter_agent_assistant_msg("continue");
let history = create_history_with_items(vec![item.clone()]);
assert_eq!(history.raw_items(), std::slice::from_ref(&item));
assert_eq!(history.for_prompt(&default_input_modalities()), vec![item]);
}
#[test]
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
let first_turn = user_input_text_msg("first");
@@ -851,6 +916,75 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
assert_eq!(history.for_prompt(&modalities), expected_prefix_only);
}
#[test]
fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
let items = vec![
assistant_msg("session prefix item"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
user_input_text_msg(
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
),
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
];
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
let reference_context_item = reference_context_item();
history.set_reference_context_item(Some(reference_context_item.clone()));
history.drop_last_n_user_turns(1);
assert_eq!(
history.clone().for_prompt(&modalities),
vec![
assistant_msg("session prefix item"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
]
);
assert_eq!(
serde_json::to_value(history.reference_context_item())
.expect("serialize retained reference context item"),
serde_json::to_value(Some(reference_context_item))
.expect("serialize expected reference context item")
);
}
#[test]
fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles() {
let items = vec![
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg_with_fragments(&[
"<permissions instructions>contextual permissions</permissions instructions>",
"persistent plugin instructions",
]),
user_input_text_msg(
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
),
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
];
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
history.set_reference_context_item(Some(reference_context_item()));
history.drop_last_n_user_turns(1);
assert_eq!(
history.clone().for_prompt(&modalities),
vec![
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
]
);
assert!(history.reference_context_item().is_none());
}
#[test]
fn remove_first_item_handles_custom_tool_pair() {
let items = vec![
+40
View File
@@ -14,6 +14,8 @@ use codex_protocol::models::is_image_close_tag_text;
use codex_protocol::models::is_image_open_tag_text;
use codex_protocol::models::is_local_image_close_tag_text;
use codex_protocol::models::is_local_image_open_tag_text;
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
use codex_protocol::user_input::UserInput;
use tracing::warn;
use uuid::Uuid;
@@ -22,10 +24,48 @@ use crate::contextual_user_message::is_contextual_user_fragment;
use crate::contextual_user_message::parse_visible_hook_prompt_message;
use crate::web_search::web_search_action_detail;
const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<permissions instructions>",
"<model_switch>",
COLLABORATION_MODE_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
"<personality_spec>",
];
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
message.iter().any(is_contextual_user_fragment)
}
/// Returns true when a developer message contains any rollback-trimmable contextual fragment.
///
/// `build_initial_context` can bundle these fragments together with persistent developer text in a
/// single developer message, so callers that care about invalidating a stored reference baseline
/// should pair this with `has_non_contextual_dev_message_content`.
pub(crate) fn is_contextual_dev_message_content(message: &[ContentItem]) -> bool {
message.iter().any(is_contextual_dev_fragment)
}
/// Returns true when a developer message contains any fragment that is not part of the
/// rollback-trimmable contextual prefix set.
pub(crate) fn has_non_contextual_dev_message_content(message: &[ContentItem]) -> bool {
message
.iter()
.any(|content_item| !is_contextual_dev_fragment(content_item))
}
fn is_contextual_dev_fragment(content_item: &ContentItem) -> bool {
let ContentItem::InputText { text } = content_item else {
return false;
};
let trimmed = text.trim_start();
CONTEXTUAL_DEVELOPER_PREFIXES.iter().any(|prefix| {
trimmed
.get(..prefix.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
})
}
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
if is_contextual_user_message_content(message) {
return None;
+446 -222
View File
@@ -1,13 +1,15 @@
//! Watches skill roots for changes and broadcasts coarse-grained
//! `FileWatcherEvent`s that higher-level components react to on the next turn.
//! Watches subscribed files or directories and routes coarse-grained change
//! notifications to the subscribers that own matching watched paths.
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use notify::Event;
@@ -16,22 +18,162 @@ use notify::RecommendedWatcher;
use notify::RecursiveMode;
use notify::Watcher;
use tokio::runtime::Handle;
use tokio::sync::broadcast;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::time::Instant;
use tokio::time::sleep_until;
use tracing::warn;
use crate::config::Config;
use crate::skills::SkillsManager;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileWatcherEvent {
SkillsChanged { paths: Vec<PathBuf> },
/// Coalesced file change notification for a subscriber.
pub struct FileWatcherEvent {
/// Changed paths delivered in sorted order with duplicates removed.
pub paths: Vec<PathBuf>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
/// Path subscription registered by a [`FileWatcherSubscriber`].
pub struct WatchPath {
/// Root path to watch.
pub path: PathBuf,
/// Whether events below `path` should match recursively.
pub recursive: bool,
}
type SubscriberId = u64;
#[derive(Default)]
struct WatchState {
skills_root_ref_counts: HashMap<PathBuf, usize>,
next_subscriber_id: SubscriberId,
path_ref_counts: HashMap<PathBuf, PathWatchCounts>,
subscribers: HashMap<SubscriberId, SubscriberState>,
}
struct SubscriberState {
watched_paths: HashMap<WatchPath, usize>,
tx: WatchSender,
}
/// Receives coalesced change notifications for a single subscriber.
pub struct Receiver {
inner: Arc<ReceiverInner>,
}
struct WatchSender {
inner: Arc<ReceiverInner>,
}
struct ReceiverInner {
changed_paths: AsyncMutex<BTreeSet<PathBuf>>,
notify: Notify,
sender_count: AtomicUsize,
}
impl Receiver {
/// Waits for the next batch of changed paths, or returns `None` once the
/// corresponding subscriber has been removed and no more events can arrive.
pub async fn recv(&mut self) -> Option<FileWatcherEvent> {
loop {
let notified = self.inner.notify.notified();
{
let mut changed_paths = self.inner.changed_paths.lock().await;
if !changed_paths.is_empty() {
return Some(FileWatcherEvent {
paths: std::mem::take(&mut *changed_paths).into_iter().collect(),
});
}
if self.inner.sender_count.load(Ordering::Acquire) == 0 {
return None;
}
}
notified.await;
}
}
}
impl WatchSender {
async fn add_changed_paths(&self, paths: &[PathBuf]) {
if paths.is_empty() {
return;
}
let mut changed_paths = self.inner.changed_paths.lock().await;
let previous_len = changed_paths.len();
changed_paths.extend(paths.iter().cloned());
if changed_paths.len() != previous_len {
self.inner.notify.notify_one();
}
}
}
impl Clone for WatchSender {
fn clone(&self) -> Self {
self.inner.sender_count.fetch_add(1, Ordering::Relaxed);
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl Drop for WatchSender {
fn drop(&mut self) {
if self.inner.sender_count.fetch_sub(1, Ordering::AcqRel) == 1 {
self.inner.notify.notify_waiters();
}
}
}
fn watch_channel() -> (WatchSender, Receiver) {
let inner = Arc::new(ReceiverInner {
changed_paths: AsyncMutex::new(BTreeSet::new()),
notify: Notify::new(),
sender_count: AtomicUsize::new(1),
});
(
WatchSender {
inner: Arc::clone(&inner),
},
Receiver { inner },
)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct PathWatchCounts {
non_recursive: usize,
recursive: usize,
}
impl PathWatchCounts {
fn increment(&mut self, recursive: bool, amount: usize) {
if recursive {
self.recursive += amount;
} else {
self.non_recursive += amount;
}
}
fn decrement(&mut self, recursive: bool, amount: usize) {
if recursive {
self.recursive = self.recursive.saturating_sub(amount);
} else {
self.non_recursive = self.non_recursive.saturating_sub(amount);
}
}
fn effective_mode(self) -> Option<RecursiveMode> {
if self.recursive > 0 {
Some(RecursiveMode::Recursive)
} else if self.non_recursive > 0 {
Some(RecursiveMode::NonRecursive)
} else {
None
}
}
fn is_empty(self) -> bool {
self.non_recursive == 0 && self.recursive == 0
}
}
struct FileWatcherInner {
@@ -39,73 +181,95 @@ struct FileWatcherInner {
watched_paths: HashMap<PathBuf, RecursiveMode>,
}
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
/// Coalesces bursts of paths and emits at most once per interval.
struct ThrottledPaths {
pending: HashSet<PathBuf>,
next_allowed_at: Instant,
/// Coalesces bursts of watch notifications and emits at most once per interval.
pub struct ThrottledWatchReceiver {
rx: Receiver,
interval: Duration,
next_allowed: Option<Instant>,
}
impl ThrottledPaths {
fn new(now: Instant) -> Self {
impl ThrottledWatchReceiver {
/// Creates a throttling wrapper around a raw watcher [`Receiver`].
pub fn new(rx: Receiver, interval: Duration) -> Self {
Self {
pending: HashSet::new(),
next_allowed_at: now,
rx,
interval,
next_allowed: None,
}
}
fn add(&mut self, paths: Vec<PathBuf>) {
self.pending.extend(paths);
}
fn next_deadline(&self, now: Instant) -> Option<Instant> {
(!self.pending.is_empty() && now < self.next_allowed_at).then_some(self.next_allowed_at)
}
fn take_ready(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
if self.pending.is_empty() || now < self.next_allowed_at {
return None;
/// Receives the next event, enforcing the configured minimum delay after
/// the previous emission.
pub async fn recv(&mut self) -> Option<FileWatcherEvent> {
if let Some(next_allowed) = self.next_allowed {
sleep_until(next_allowed).await;
}
Some(self.take_with_next_allowed(now))
}
fn take_pending(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
if self.pending.is_empty() {
return None;
let event = self.rx.recv().await;
if event.is_some() {
self.next_allowed = Some(Instant::now() + self.interval);
}
Some(self.take_with_next_allowed(now))
}
fn take_with_next_allowed(&mut self, now: Instant) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = self.pending.drain().collect();
paths.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
self.next_allowed_at = now + WATCHER_THROTTLE_INTERVAL;
paths
event
}
}
pub(crate) struct FileWatcher {
inner: Option<Mutex<FileWatcherInner>>,
state: Arc<RwLock<WatchState>>,
tx: broadcast::Sender<FileWatcherEvent>,
/// Handle used to register watched paths for one logical consumer.
pub struct FileWatcherSubscriber {
id: SubscriberId,
file_watcher: Arc<FileWatcher>,
}
pub(crate) struct WatchRegistration {
impl FileWatcherSubscriber {
/// Registers the provided paths for this subscriber and returns an RAII
/// guard that unregisters them on drop.
pub fn register_paths(&self, watched_paths: Vec<WatchPath>) -> WatchRegistration {
let watched_paths = dedupe_watched_paths(watched_paths);
self.file_watcher.register_paths(self.id, &watched_paths);
WatchRegistration {
file_watcher: Arc::downgrade(&self.file_watcher),
subscriber_id: self.id,
watched_paths,
}
}
#[cfg(test)]
pub(crate) fn register_path(&self, path: PathBuf, recursive: bool) -> WatchRegistration {
self.register_paths(vec![WatchPath { path, recursive }])
}
}
impl Drop for FileWatcherSubscriber {
fn drop(&mut self) {
self.file_watcher.remove_subscriber(self.id);
}
}
/// RAII guard for a set of active path registrations.
pub struct WatchRegistration {
file_watcher: std::sync::Weak<FileWatcher>,
roots: Vec<PathBuf>,
subscriber_id: SubscriberId,
watched_paths: Vec<WatchPath>,
}
impl Drop for WatchRegistration {
fn drop(&mut self) {
if let Some(file_watcher) = self.file_watcher.upgrade() {
file_watcher.unregister_roots(&self.roots);
file_watcher.unregister_paths(self.subscriber_id, &self.watched_paths);
}
}
}
/// Multi-subscriber file watcher built on top of `notify`.
pub struct FileWatcher {
inner: Option<Mutex<FileWatcherInner>>,
state: Arc<RwLock<WatchState>>,
}
impl FileWatcher {
pub(crate) fn new(_codex_home: PathBuf) -> notify::Result<Self> {
/// Creates a live filesystem watcher and starts its background event loop
/// on the current Tokio runtime.
pub fn new() -> notify::Result<Self> {
let (raw_tx, raw_rx) = mpsc::unbounded_channel();
let raw_tx_clone = raw_tx;
let watcher = notify::recommended_watcher(move |res| {
@@ -115,109 +279,101 @@ impl FileWatcher {
watcher,
watched_paths: HashMap::new(),
};
let (tx, _) = broadcast::channel(128);
let state = Arc::new(RwLock::new(WatchState {
skills_root_ref_counts: HashMap::new(),
}));
let state = Arc::new(RwLock::new(WatchState::default()));
let file_watcher = Self {
inner: Some(Mutex::new(inner)),
state: Arc::clone(&state),
tx: tx.clone(),
state,
};
file_watcher.spawn_event_loop(raw_rx, state, tx);
file_watcher.spawn_event_loop(raw_rx);
Ok(file_watcher)
}
pub(crate) fn noop() -> Self {
let (tx, _) = broadcast::channel(1);
/// Creates an inert watcher that only supports test-driven synthetic
/// notifications.
pub fn noop() -> Self {
Self {
inner: None,
state: Arc::new(RwLock::new(WatchState {
skills_root_ref_counts: HashMap::new(),
})),
tx,
state: Arc::new(RwLock::new(WatchState::default())),
}
}
pub(crate) fn subscribe(&self) -> broadcast::Receiver<FileWatcherEvent> {
self.tx.subscribe()
/// Adds a new subscriber and returns both its registration handle and its
/// dedicated event receiver.
pub fn add_subscriber(self: &Arc<Self>) -> (FileWatcherSubscriber, Receiver) {
let (tx, rx) = watch_channel();
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let subscriber_id = state.next_subscriber_id;
state.next_subscriber_id += 1;
state.subscribers.insert(
subscriber_id,
SubscriberState {
watched_paths: HashMap::new(),
tx,
},
);
let subscriber = FileWatcherSubscriber {
id: subscriber_id,
file_watcher: self.clone(),
};
(subscriber, rx)
}
pub(crate) fn register_config(
self: &Arc<Self>,
config: &Config,
skills_manager: &SkillsManager,
) -> WatchRegistration {
let deduped_roots: HashSet<PathBuf> = skills_manager
.skill_roots_for_config(config)
.into_iter()
.map(|root| root.path)
.collect();
let mut registered_roots: Vec<PathBuf> = deduped_roots.into_iter().collect();
registered_roots.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
for root in &registered_roots {
self.register_skills_root(root.clone());
}
fn register_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
WatchRegistration {
file_watcher: Arc::downgrade(self),
roots: registered_roots,
for watched_path in watched_paths {
{
let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else {
return;
};
*subscriber
.watched_paths
.entry(watched_path.clone())
.or_default() += 1;
}
let counts = state
.path_ref_counts
.entry(watched_path.path.clone())
.or_default();
let previous_mode = counts.effective_mode();
counts.increment(watched_path.recursive, /*amount*/ 1);
let next_mode = counts.effective_mode();
if previous_mode != next_mode {
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
}
}
}
// Bridge `notify`'s callback-based events into the Tokio runtime and
// broadcast coarse-grained change signals to subscribers.
fn spawn_event_loop(
&self,
mut raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>,
state: Arc<RwLock<WatchState>>,
tx: broadcast::Sender<FileWatcherEvent>,
) {
// notify the matching subscribers.
fn spawn_event_loop(&self, mut raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>) {
if let Ok(handle) = Handle::try_current() {
let state = Arc::clone(&self.state);
handle.spawn(async move {
let now = Instant::now();
let mut skills = ThrottledPaths::new(now);
loop {
let now = Instant::now();
let next_deadline = skills.next_deadline(now);
let timer_deadline = next_deadline
.unwrap_or_else(|| now + Duration::from_secs(60 * 60 * 24 * 365));
let timer = sleep_until(timer_deadline);
tokio::pin!(timer);
tokio::select! {
res = raw_rx.recv() => {
match res {
Some(Ok(event)) => {
let skills_paths = classify_event(&event, &state);
let now = Instant::now();
skills.add(skills_paths);
if let Some(paths) = skills.take_ready(now) {
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
}
}
Some(Err(err)) => {
warn!("file watcher error: {err}");
}
None => {
// Flush any pending changes before shutdown so subscribers
// see the latest state.
let now = Instant::now();
if let Some(paths) = skills.take_pending(now) {
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
}
break;
}
match raw_rx.recv().await {
Some(Ok(event)) => {
if !is_mutating_event(&event) {
continue;
}
}
_ = &mut timer => {
let now = Instant::now();
if let Some(paths) = skills.take_ready(now) {
let _ = tx.send(FileWatcherEvent::SkillsChanged { paths });
if event.paths.is_empty() {
continue;
}
Self::notify_subscribers(&state, &event.paths).await;
}
Some(Err(err)) => {
warn!("file watcher error: {err}");
}
None => break,
}
}
});
@@ -226,127 +382,195 @@ impl FileWatcher {
}
}
fn register_skills_root(&self, root: PathBuf) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let count = state
.skills_root_ref_counts
.entry(root.clone())
.or_insert(0);
*count += 1;
if *count == 1 {
self.watch_path(root, RecursiveMode::Recursive);
}
}
fn unregister_roots(&self, roots: &[PathBuf]) {
fn unregister_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
for root in roots {
let mut should_unwatch = false;
if let Some(count) = state.skills_root_ref_counts.get_mut(root) {
if *count > 1 {
*count -= 1;
} else {
state.skills_root_ref_counts.remove(root);
should_unwatch = true;
for watched_path in watched_paths {
{
let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else {
return;
};
let Some(subscriber_count) = subscriber.watched_paths.get_mut(watched_path) else {
continue;
};
*subscriber_count = subscriber_count.saturating_sub(1);
if *subscriber_count == 0 {
subscriber.watched_paths.remove(watched_path);
}
}
if !should_unwatch {
continue;
}
let Some(inner) = &self.inner else {
let Some(counts) = state.path_ref_counts.get_mut(&watched_path.path) else {
continue;
};
if inner_guard.is_none() {
let guard = inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
inner_guard = Some(guard);
let previous_mode = counts.effective_mode();
counts.decrement(watched_path.recursive, /*amount*/ 1);
let next_mode = counts.effective_mode();
if counts.is_empty() {
state.path_ref_counts.remove(&watched_path.path);
}
let Some(guard) = inner_guard.as_mut() else {
continue;
};
if guard.watched_paths.remove(root).is_none() {
continue;
}
if let Err(err) = guard.watcher.unwatch(root) {
warn!("failed to unwatch {}: {err}", root.display());
if previous_mode != next_mode {
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
}
}
}
fn watch_path(&self, path: PathBuf, mode: RecursiveMode) {
fn remove_subscriber(&self, subscriber_id: SubscriberId) {
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(subscriber) = state.subscribers.remove(&subscriber_id) else {
return;
};
let mut inner_guard: Option<std::sync::MutexGuard<'_, FileWatcherInner>> = None;
for (watched_path, count) in subscriber.watched_paths {
let Some(path_counts) = state.path_ref_counts.get_mut(&watched_path.path) else {
continue;
};
let previous_mode = path_counts.effective_mode();
path_counts.decrement(watched_path.recursive, count);
let next_mode = path_counts.effective_mode();
if path_counts.is_empty() {
state.path_ref_counts.remove(&watched_path.path);
}
if previous_mode != next_mode {
self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard);
}
}
}
fn reconfigure_watch<'a>(
&'a self,
path: &Path,
next_mode: Option<RecursiveMode>,
inner_guard: &mut Option<std::sync::MutexGuard<'a, FileWatcherInner>>,
) {
let Some(inner) = &self.inner else {
return;
};
if inner_guard.is_none() {
let guard = inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*inner_guard = Some(guard);
}
let Some(guard) = inner_guard.as_mut() else {
return;
};
let existing_mode = guard.watched_paths.get(path).copied();
if existing_mode == next_mode {
return;
}
if existing_mode.is_some() {
if let Err(err) = guard.watcher.unwatch(path) {
warn!("failed to unwatch {}: {err}", path.display());
}
guard.watched_paths.remove(path);
}
let Some(next_mode) = next_mode else {
return;
};
if !path.exists() {
return;
}
let watch_path = path;
let mut guard = inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(existing) = guard.watched_paths.get(&watch_path) {
if *existing == RecursiveMode::Recursive || *existing == mode {
return;
}
if let Err(err) = guard.watcher.unwatch(&watch_path) {
warn!("failed to unwatch {}: {err}", watch_path.display());
}
}
if let Err(err) = guard.watcher.watch(&watch_path, mode) {
warn!("failed to watch {}: {err}", watch_path.display());
if let Err(err) = guard.watcher.watch(path, next_mode) {
warn!("failed to watch {}: {err}", path.display());
return;
}
guard.watched_paths.insert(watch_path, mode);
guard.watched_paths.insert(path.to_path_buf(), next_mode);
}
async fn notify_subscribers(state: &RwLock<WatchState>, event_paths: &[PathBuf]) {
let subscribers_to_notify: Vec<(WatchSender, Vec<PathBuf>)> = {
let state = state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state
.subscribers
.values()
.filter_map(|subscriber| {
let changed_paths: Vec<PathBuf> = event_paths
.iter()
.filter(|event_path| {
subscriber.watched_paths.keys().any(|watched_path| {
watch_path_matches_event(watched_path, event_path)
})
})
.cloned()
.collect();
(!changed_paths.is_empty()).then_some((subscriber.tx.clone(), changed_paths))
})
.collect()
};
for (subscriber, changed_paths) in subscribers_to_notify {
subscriber.add_changed_paths(&changed_paths).await;
}
}
#[cfg(test)]
pub(crate) async fn send_paths_for_test(&self, paths: Vec<PathBuf>) {
Self::notify_subscribers(&self.state, &paths).await;
}
#[cfg(test)]
pub(crate) fn spawn_event_loop_for_test(
&self,
raw_rx: mpsc::UnboundedReceiver<notify::Result<Event>>,
) {
self.spawn_event_loop(raw_rx);
}
#[cfg(test)]
pub(crate) fn watch_counts_for_test(&self, path: &Path) -> Option<(usize, usize)> {
let state = self
.state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state
.path_ref_counts
.get(path)
.map(|counts| (counts.non_recursive, counts.recursive))
}
}
fn classify_event(event: &Event, state: &RwLock<WatchState>) -> Vec<PathBuf> {
if !matches!(
fn is_mutating_event(event: &Event) -> bool {
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) {
return Vec::new();
}
let mut skills_paths = Vec::new();
let skills_roots = match state.read() {
Ok(state) => state
.skills_root_ref_counts
.keys()
.cloned()
.collect::<HashSet<_>>(),
Err(err) => {
let state = err.into_inner();
state
.skills_root_ref_counts
.keys()
.cloned()
.collect::<HashSet<_>>()
}
};
for path in &event.paths {
if is_skills_path(path, &skills_roots) {
skills_paths.push(path.clone());
}
}
skills_paths
)
}
fn is_skills_path(path: &Path, roots: &HashSet<PathBuf>) -> bool {
roots.iter().any(|root| path.starts_with(root))
fn dedupe_watched_paths(mut watched_paths: Vec<WatchPath>) -> Vec<WatchPath> {
watched_paths.sort_unstable_by(|a, b| {
a.path
.as_os_str()
.cmp(b.path.as_os_str())
.then(a.recursive.cmp(&b.recursive))
});
watched_paths.dedup();
watched_paths
}
fn watch_path_matches_event(watched_path: &WatchPath, event_path: &Path) -> bool {
if event_path == watched_path.path {
return true;
}
if watched_path.path.starts_with(event_path) {
return true;
}
if !event_path.starts_with(&watched_path.path) {
return false;
}
watched_path.recursive || event_path.parent() == Some(watched_path.path.as_path())
}
#[cfg(test)]
+273 -165
View File
@@ -1,13 +1,13 @@
use super::*;
use notify::EventKind;
use notify::event::AccessKind;
use notify::event::AccessMode;
use notify::event::CreateKind;
use notify::event::ModifyKind;
use notify::event::RemoveKind;
use pretty_assertions::assert_eq;
use tokio::time::timeout;
const TEST_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
fn path(name: &str) -> PathBuf {
PathBuf::from(name)
}
@@ -20,147 +20,202 @@ fn notify_event(kind: EventKind, paths: Vec<PathBuf>) -> Event {
event
}
#[test]
fn throttles_and_coalesces_within_interval() {
let start = Instant::now();
let mut throttled = ThrottledPaths::new(start);
#[tokio::test]
async fn throttled_receiver_coalesces_within_interval() {
let (tx, rx) = watch_channel();
let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
throttled.add(vec![path("a")]);
let first = throttled.take_ready(start).expect("first emit");
assert_eq!(first, vec![path("a")]);
throttled.add(vec![path("b"), path("c")]);
assert_eq!(throttled.take_ready(start), None);
let second = throttled
.take_ready(start + WATCHER_THROTTLE_INTERVAL)
.expect("coalesced emit");
assert_eq!(second, vec![path("b"), path("c")]);
}
#[test]
fn flushes_pending_on_shutdown() {
let start = Instant::now();
let mut throttled = ThrottledPaths::new(start);
throttled.add(vec![path("a")]);
let _ = throttled.take_ready(start).expect("first emit");
throttled.add(vec![path("b")]);
assert_eq!(throttled.take_ready(start), None);
let flushed = throttled
.take_pending(start)
.expect("shutdown flush emits pending paths");
assert_eq!(flushed, vec![path("b")]);
}
#[test]
fn classify_event_filters_to_skills_roots() {
let root = path("/tmp/skills");
let state = RwLock::new(WatchState {
skills_root_ref_counts: HashMap::from([(root.clone(), 1)]),
});
let event = notify_event(
EventKind::Create(CreateKind::Any),
vec![
root.join("demo/SKILL.md"),
path("/tmp/other/not-a-skill.txt"),
],
);
let classified = classify_event(&event, &state);
assert_eq!(classified, vec![root.join("demo/SKILL.md")]);
}
#[test]
fn classify_event_supports_multiple_roots_without_prefix_false_positives() {
let root_a = path("/tmp/skills");
let root_b = path("/tmp/workspace/.codex/skills");
let state = RwLock::new(WatchState {
skills_root_ref_counts: HashMap::from([(root_a.clone(), 1), (root_b.clone(), 1)]),
});
let event = notify_event(
EventKind::Modify(ModifyKind::Any),
vec![
root_a.join("alpha/SKILL.md"),
path("/tmp/skills-extra/not-under-skills.txt"),
root_b.join("beta/SKILL.md"),
],
);
let classified = classify_event(&event, &state);
tx.add_changed_paths(&[path("a")]).await;
let first = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("first emit timeout");
assert_eq!(
classified,
vec![root_a.join("alpha/SKILL.md"), root_b.join("beta/SKILL.md")]
first,
Some(FileWatcherEvent {
paths: vec![path("a")],
})
);
tx.add_changed_paths(&[path("b"), path("c")]).await;
let blocked = timeout(TEST_THROTTLE_INTERVAL / 2, throttled.recv()).await;
assert_eq!(blocked.is_err(), true);
let second = timeout(TEST_THROTTLE_INTERVAL * 2, throttled.recv())
.await
.expect("second emit timeout");
assert_eq!(
second,
Some(FileWatcherEvent {
paths: vec![path("b"), path("c")],
})
);
}
#[tokio::test]
async fn throttled_receiver_flushes_pending_on_shutdown() {
let (tx, rx) = watch_channel();
let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
tx.add_changed_paths(&[path("a")]).await;
let first = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("first emit timeout");
assert_eq!(
first,
Some(FileWatcherEvent {
paths: vec![path("a")],
})
);
tx.add_changed_paths(&[path("b")]).await;
drop(tx);
let second = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("shutdown flush timeout");
assert_eq!(
second,
Some(FileWatcherEvent {
paths: vec![path("b")],
})
);
let closed = timeout(Duration::from_secs(1), throttled.recv())
.await
.expect("closed recv timeout");
assert_eq!(closed, None);
}
#[test]
fn is_mutating_event_filters_non_mutating_event_kinds() {
assert_eq!(
is_mutating_event(&notify_event(
EventKind::Create(CreateKind::Any),
vec![path("/tmp/created")]
)),
true
);
assert_eq!(
is_mutating_event(&notify_event(
EventKind::Modify(ModifyKind::Any),
vec![path("/tmp/modified")]
)),
true
);
assert_eq!(
is_mutating_event(&notify_event(
EventKind::Access(AccessKind::Open(AccessMode::Any)),
vec![path("/tmp/accessed")]
)),
false
);
}
#[test]
fn classify_event_ignores_non_mutating_event_kinds() {
let root = path("/tmp/skills");
let state = RwLock::new(WatchState {
skills_root_ref_counts: HashMap::from([(root.clone(), 1)]),
});
let path = root.join("demo/SKILL.md");
let access_event = notify_event(
EventKind::Access(AccessKind::Open(AccessMode::Any)),
vec![path.clone()],
);
assert_eq!(classify_event(&access_event, &state), Vec::<PathBuf>::new());
let any_event = notify_event(EventKind::Any, vec![path.clone()]);
assert_eq!(classify_event(&any_event, &state), Vec::<PathBuf>::new());
let other_event = notify_event(EventKind::Other, vec![path]);
assert_eq!(classify_event(&other_event, &state), Vec::<PathBuf>::new());
}
#[test]
fn register_skills_root_dedupes_state_entries() {
let watcher = FileWatcher::noop();
let root = path("/tmp/skills");
watcher.register_skills_root(root.clone());
watcher.register_skills_root(root);
watcher.register_skills_root(path("/tmp/other-skills"));
let state = watcher.state.read().expect("state lock");
assert_eq!(state.skills_root_ref_counts.len(), 2);
}
#[test]
fn watch_registration_drop_unregisters_roots() {
fn register_dedupes_by_path_and_scope() {
let watcher = Arc::new(FileWatcher::noop());
let root = path("/tmp/skills");
watcher.register_skills_root(root.clone());
let registration = WatchRegistration {
file_watcher: Arc::downgrade(&watcher),
roots: vec![root],
};
let (subscriber, _rx) = watcher.add_subscriber();
let _first = subscriber.register_path(path("/tmp/skills"), false);
let _second = subscriber.register_path(path("/tmp/skills"), false);
let _third = subscriber.register_path(path("/tmp/skills"), true);
let _fourth = subscriber.register_path(path("/tmp/other-skills"), true);
assert_eq!(
watcher.watch_counts_for_test(&path("/tmp/skills")),
Some((2, 1))
);
assert_eq!(
watcher.watch_counts_for_test(&path("/tmp/other-skills")),
Some((0, 1))
);
}
#[test]
fn watch_registration_drop_unregisters_paths() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, _rx) = watcher.add_subscriber();
let registration = subscriber.register_path(path("/tmp/skills"), true);
drop(registration);
let state = watcher.state.read().expect("state lock");
assert_eq!(state.skills_root_ref_counts.len(), 0);
assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None);
}
#[test]
fn subscriber_drop_unregisters_paths() {
let watcher = Arc::new(FileWatcher::noop());
let registration = {
let (subscriber, _rx) = watcher.add_subscriber();
subscriber.register_path(path("/tmp/skills"), true)
};
assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None);
drop(registration);
}
#[tokio::test]
async fn receiver_closes_when_subscriber_drops() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, mut rx) = watcher.add_subscriber();
drop(subscriber);
let closed = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("closed recv timeout");
assert_eq!(closed, None);
}
#[test]
fn recursive_registration_downgrades_to_non_recursive_after_drop() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let root = temp_dir.path().join("watched-dir");
std::fs::create_dir(&root).expect("create root");
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
let (subscriber, _rx) = watcher.add_subscriber();
let non_recursive = subscriber.register_path(root.clone(), false);
let recursive = subscriber.register_path(root.clone(), true);
{
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner = inner.lock().expect("inner lock");
assert_eq!(
inner.watched_paths.get(&root),
Some(&RecursiveMode::Recursive)
);
}
drop(recursive);
{
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner = inner.lock().expect("inner lock");
assert_eq!(
inner.watched_paths.get(&root),
Some(&RecursiveMode::NonRecursive)
);
}
drop(non_recursive);
}
#[test]
fn unregister_holds_state_lock_until_unwatch_finishes() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let root = temp_dir.path().join("skills");
let root = temp_dir.path().join("watched-dir");
std::fs::create_dir(&root).expect("create root");
let watcher = Arc::new(FileWatcher::new(temp_dir.path().to_path_buf()).expect("watcher"));
watcher.register_skills_root(root.clone());
let watcher = Arc::new(FileWatcher::new().expect("watcher"));
let (unregister_subscriber, _unregister_rx) = watcher.add_subscriber();
let (register_subscriber, _register_rx) = watcher.add_subscriber();
let registration = unregister_subscriber.register_path(root.clone(), true);
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner_guard = inner.lock().expect("inner lock");
let unregister_watcher = Arc::clone(&watcher);
let unregister_root = root.clone();
let unregister_thread = std::thread::spawn(move || {
unregister_watcher.unregister_roots(&[unregister_root]);
drop(registration);
});
let state_lock_observed = (0..100).any(|_| {
@@ -172,75 +227,128 @@ fn unregister_holds_state_lock_until_unwatch_finishes() {
});
assert_eq!(state_lock_observed, true);
let register_watcher = Arc::clone(&watcher);
let register_root = root.clone();
let register_thread = std::thread::spawn(move || {
register_watcher.register_skills_root(register_root);
let registration = register_subscriber.register_path(register_root, false);
(register_subscriber, registration)
});
drop(inner_guard);
unregister_thread.join().expect("unregister join");
register_thread.join().expect("register join");
let (register_subscriber, non_recursive) = register_thread.join().expect("register join");
let state = watcher.state.read().expect("state lock");
assert_eq!(state.skills_root_ref_counts.get(&root), Some(&1));
drop(state);
assert_eq!(watcher.watch_counts_for_test(&root), Some((1, 0)));
let inner = watcher.inner.as_ref().expect("watcher inner");
let inner = inner.lock().expect("inner lock");
assert_eq!(
inner.watched_paths.get(&root),
Some(&RecursiveMode::Recursive)
Some(&RecursiveMode::NonRecursive)
);
drop(inner);
drop(non_recursive);
drop(register_subscriber);
}
#[tokio::test]
async fn matching_subscribers_are_notified() {
let watcher = Arc::new(FileWatcher::noop());
let (skills_subscriber, skills_rx) = watcher.add_subscriber();
let (plugins_subscriber, plugins_rx) = watcher.add_subscriber();
let _skills = skills_subscriber.register_path(path("/tmp/skills"), true);
let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), true);
let mut skills_rx = ThrottledWatchReceiver::new(skills_rx, TEST_THROTTLE_INTERVAL);
let mut plugins_rx = ThrottledWatchReceiver::new(plugins_rx, TEST_THROTTLE_INTERVAL);
watcher
.send_paths_for_test(vec![path("/tmp/skills/rust/SKILL.md")])
.await;
let skills_event = timeout(Duration::from_secs(1), skills_rx.recv())
.await
.expect("skills change timeout")
.expect("skills change");
assert_eq!(
skills_event,
FileWatcherEvent {
paths: vec![path("/tmp/skills/rust/SKILL.md")],
}
);
let plugins_event = timeout(TEST_THROTTLE_INTERVAL, plugins_rx.recv()).await;
assert_eq!(plugins_event.is_err(), true);
}
#[tokio::test]
async fn non_recursive_watch_ignores_grandchildren() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills"), false);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
watcher
.send_paths_for_test(vec![path("/tmp/skills/nested/SKILL.md")])
.await;
let event = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await;
assert_eq!(event.is_err(), true);
}
#[tokio::test]
async fn ancestor_events_notify_child_watches() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), false);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
watcher.send_paths_for_test(vec![path("/tmp/skills")]).await;
let event = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("ancestor event timeout")
.expect("ancestor event");
assert_eq!(
event,
FileWatcherEvent {
paths: vec![path("/tmp/skills")],
}
);
}
#[tokio::test]
async fn spawn_event_loop_flushes_pending_changes_on_shutdown() {
let watcher = FileWatcher::noop();
let root = path("/tmp/skills");
{
let mut state = watcher.state.write().expect("state lock");
state.skills_root_ref_counts.insert(root.clone(), 1);
}
async fn spawn_event_loop_filters_non_mutating_events() {
let watcher = Arc::new(FileWatcher::noop());
let (subscriber, rx) = watcher.add_subscriber();
let _registration = subscriber.register_path(path("/tmp/skills"), true);
let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL);
let (raw_tx, raw_rx) = mpsc::unbounded_channel();
let (tx, mut rx) = broadcast::channel(8);
watcher.spawn_event_loop(raw_rx, Arc::clone(&watcher.state), tx);
watcher.spawn_event_loop_for_test(raw_rx);
raw_tx
.send(Ok(notify_event(
EventKind::Access(AccessKind::Open(AccessMode::Any)),
vec![path("/tmp/skills/SKILL.md")],
)))
.expect("send access event");
let blocked = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await;
assert_eq!(blocked.is_err(), true);
raw_tx
.send(Ok(notify_event(
EventKind::Create(CreateKind::File),
vec![root.join("a/SKILL.md")],
vec![path("/tmp/skills/SKILL.md")],
)))
.expect("send first event");
let first = timeout(Duration::from_secs(2), rx.recv())
.expect("send create event");
let event = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("first watcher event")
.expect("broadcast recv first");
.expect("create event timeout")
.expect("create event");
assert_eq!(
first,
FileWatcherEvent::SkillsChanged {
paths: vec![root.join("a/SKILL.md")]
}
);
raw_tx
.send(Ok(notify_event(
EventKind::Remove(RemoveKind::File),
vec![root.join("b/SKILL.md")],
)))
.expect("send second event");
drop(raw_tx);
let second = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("second watcher event")
.expect("broadcast recv second");
assert_eq!(
second,
FileWatcherEvent::SkillsChanged {
paths: vec![root.join("b/SKILL.md")]
event,
FileWatcherEvent {
paths: vec![path("/tmp/skills/SKILL.md")],
}
);
}
+10 -53
View File
@@ -1,10 +1,15 @@
use super::*;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::GitInfo;
use codex_git_utils::GitSha;
use codex_git_utils::collect_git_info;
use codex_git_utils::get_has_changes;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::recent_commits;
use codex_git_utils::resolve_root_git_project_for_trust;
use core_test_support::skip_if_sandbox;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::process::Command;
// Helper function to create a test git repository
async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
@@ -192,7 +197,7 @@ async fn test_collect_git_info_git_repository() {
// Should have commit hash
assert!(git_info.commit_hash.is_some());
let commit_hash = git_info.commit_hash.unwrap();
let commit_hash = git_info.commit_hash.unwrap().0;
assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters
assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));
@@ -449,23 +454,6 @@ async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_with_fs_regular_repo_returns_repo_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
let expected = std::fs::canonicalize(&repo_path).unwrap();
assert_eq!(
resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &repo_path).await,
Some(expected.clone())
);
let nested = repo_path.join("sub/dir");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(
resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &nested).await,
Some(expected)
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
@@ -498,37 +486,6 @@ async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_ro
assert_eq!(got_nested, expected);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_with_fs_detects_worktree_and_returns_main_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
let wt_root = temp_dir.path().join("wt");
let _ = std::process::Command::new("git")
.args([
"worktree",
"add",
wt_root.to_str().unwrap(),
"-b",
"feature/x",
])
.current_dir(&repo_path)
.output()
.expect("git worktree add");
let expected = std::fs::canonicalize(&repo_path).ok();
let got = resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &wt_root)
.await
.and_then(|path| std::fs::canonicalize(path).ok());
assert_eq!(got, expected);
let nested = wt_root.join("nested/sub");
std::fs::create_dir_all(&nested).unwrap();
let got_nested = resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &nested)
.await
.and_then(|path| std::fs::canonicalize(path).ok());
assert_eq!(got_nested, expected);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
let tmp = TempDir::new().expect("tempdir");
@@ -620,7 +577,7 @@ async fn test_get_git_working_tree_state_unpushed_commit() {
#[test]
fn test_git_info_serialization() {
let git_info = GitInfo {
commit_hash: Some("abc123def456".to_string()),
commit_hash: Some(GitSha::new("abc123def456")),
branch: Some("main".to_string()),
repository_url: Some("https://github.com/example/repo.git".to_string()),
};
+5 -2
View File
@@ -39,9 +39,10 @@ pub mod exec;
pub mod exec_env;
mod exec_policy;
pub mod external_agent_config;
mod file_watcher;
pub mod file_watcher;
mod flags;
pub mod git_info;
#[cfg(test)]
mod git_info_tests;
mod guardian;
mod hook_runtime;
pub mod instructions;
@@ -72,6 +73,7 @@ pub mod sandboxing;
mod session_prefix;
mod session_startup_prewarm;
mod shell_detect;
mod skills_watcher;
mod stream_events_utils;
pub mod test_support;
mod text_encoding;
@@ -173,6 +175,7 @@ pub use client_common::Prompt;
pub use client_common::REVIEW_PROMPT;
pub use client_common::ResponseEvent;
pub use client_common::ResponseStream;
pub use codex_sandboxing::get_platform_sandbox;
pub use compact::content_items_to_text;
pub use event_mapping::parse_turn_item;
pub use exec_policy::ExecPolicyError;
+2 -1
View File
@@ -39,7 +39,8 @@ pub(crate) fn list_tool_suggest_discoverable_plugins(
.collect::<HashSet<_>>();
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.context("failed to list plugin marketplaces for tool suggestions")?;
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let Some(curated_marketplace) = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
+26 -7
View File
@@ -4,6 +4,7 @@ use super::load_plugin_manifest;
use super::manifest::PluginManifestInterface;
use super::marketplace::MarketplaceError;
use super::marketplace::MarketplaceInterface;
use super::marketplace::MarketplaceListError;
use super::marketplace::MarketplacePluginAuthPolicy;
use super::marketplace::MarketplacePluginPolicy;
use super::marketplace::MarketplacePluginSource;
@@ -178,6 +179,12 @@ pub struct ConfiguredMarketplacePlugin {
pub enabled: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConfiguredMarketplaceListOutcome {
pub marketplaces: Vec<ConfiguredMarketplace>,
pub errors: Vec<MarketplaceListError>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
pub config_name: String,
@@ -996,16 +1003,16 @@ impl PluginsManager {
&self,
config: &Config,
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<ConfiguredMarketplace>, MarketplaceError> {
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
return Ok(ConfiguredMarketplaceListOutcome::default());
}
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?;
let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?;
let mut seen_plugin_keys = HashSet::new();
Ok(marketplaces
let marketplaces = marketplace_outcome
.marketplaces
.into_iter()
.filter_map(|marketplace| {
let marketplace_name = marketplace.name.clone();
@@ -1043,7 +1050,12 @@ impl PluginsManager {
plugins,
})
})
.collect())
.collect();
Ok(ConfiguredMarketplaceListOutcome {
marketplaces,
errors: marketplace_outcome.errors,
})
}
pub fn read_plugin_for_config(
@@ -1550,7 +1562,14 @@ fn load_plugin(
};
let manifest_paths = &manifest.paths;
loaded_plugin.manifest_name = Some(manifest.name.clone());
loaded_plugin.manifest_name = manifest
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.or_else(|| Some(manifest.name.clone()));
loaded_plugin.manifest_description = manifest.description.clone();
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths);
let resolved_skills = load_plugin_skills(
+13 -6
View File
@@ -1035,7 +1035,8 @@ enabled = false
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
@@ -1130,7 +1131,8 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(marketplaces, Vec::new());
}
@@ -1177,7 +1179,8 @@ plugins = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
@@ -1382,7 +1385,8 @@ plugins = true
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.unwrap();
.unwrap()
.marketplaces;
let curated_marketplace = marketplaces
.into_iter()
@@ -1487,7 +1491,8 @@ enabled = false
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
],
)
.unwrap();
.unwrap()
.marketplaces;
let repo_a_marketplace = marketplaces
.iter()
@@ -1590,7 +1595,8 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
@@ -1733,6 +1739,7 @@ enabled = true
let curated_marketplace = manager
.list_marketplaces_for_config(&synced_config, &[])
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
.unwrap();
+22 -6
View File
@@ -2,9 +2,9 @@ use super::PluginManifestInterface;
use super::load_plugin_manifest;
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_git_utils::get_git_repo_root;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
@@ -33,6 +33,18 @@ pub struct Marketplace {
pub plugins: Vec<MarketplacePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceListError {
pub path: AbsolutePathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MarketplaceListOutcome {
pub marketplaces: Vec<Marketplace>,
pub errors: Vec<MarketplaceListError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceInterface {
pub display_name: Option<String>,
@@ -195,7 +207,7 @@ pub fn resolve_marketplace_plugin(
pub fn list_marketplaces(
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<Marketplace>, MarketplaceError> {
) -> Result<MarketplaceListOutcome, MarketplaceError> {
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
}
@@ -246,23 +258,27 @@ pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Ma
fn list_marketplaces_with_home(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Result<Vec<Marketplace>, MarketplaceError> {
let mut marketplaces = Vec::new();
) -> Result<MarketplaceListOutcome, MarketplaceError> {
let mut outcome = MarketplaceListOutcome::default();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
match load_marketplace(&marketplace_path) {
Ok(marketplace) => marketplaces.push(marketplace),
Ok(marketplace) => outcome.marketplaces.push(marketplace),
Err(err) => {
warn!(
path = %marketplace_path.display(),
error = %err,
"skipping marketplace that failed to load"
);
outcome.errors.push(MarketplaceListError {
path: marketplace_path,
message: err.to_string(),
});
}
}
}
Ok(marketplaces)
Ok(outcome)
}
fn discover_marketplace_paths_from_roots(
+69 -8
View File
@@ -130,7 +130,8 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
Some(&home_root),
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
@@ -254,7 +255,8 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
Some(&home_root),
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
@@ -342,7 +344,8 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
],
None,
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
@@ -396,7 +399,8 @@ fn list_marketplaces_reads_marketplace_display_name() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].interface,
@@ -456,12 +460,66 @@ fn list_marketplaces_skips_marketplaces_that_fail_to_load() {
],
None,
)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, "valid-marketplace");
}
#[test]
fn list_marketplaces_reports_marketplace_load_errors() {
let tmp = tempdir().unwrap();
let valid_repo_root = tmp.path().join("valid-repo");
let invalid_repo_root = tmp.path().join("invalid-repo");
fs::create_dir_all(valid_repo_root.join(".git")).unwrap();
fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(invalid_repo_root.join(".git")).unwrap();
fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap();
fs::write(
valid_repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "valid-marketplace",
"plugins": [
{
"name": "valid-plugin",
"source": {
"source": "local",
"path": "./plugin"
}
}
]
}"#,
)
.unwrap();
let invalid_marketplace_path =
AbsolutePathBuf::try_from(invalid_repo_root.join(".agents/plugins/marketplace.json"))
.unwrap();
fs::write(invalid_marketplace_path.as_path(), "{not json").unwrap();
let outcome = list_marketplaces_with_home(
&[
AbsolutePathBuf::try_from(valid_repo_root).unwrap(),
AbsolutePathBuf::try_from(invalid_repo_root).unwrap(),
],
None,
)
.unwrap();
assert_eq!(outcome.marketplaces.len(), 1);
assert_eq!(outcome.marketplaces[0].name, "valid-marketplace");
assert_eq!(outcome.errors.len(), 1);
assert_eq!(outcome.errors[0].path, invalid_marketplace_path);
assert!(
outcome.errors[0]
.message
.contains("invalid marketplace file"),
"unexpected errors: {:?}",
outcome.errors
);
}
#[test]
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
let tmp = tempdir().unwrap();
@@ -510,7 +568,8 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].policy.installation,
@@ -577,7 +636,8 @@ fn list_marketplaces_ignores_legacy_top_level_policy_fields() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].policy.installation,
@@ -632,7 +692,8 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
.unwrap()
.marketplaces;
assert_eq!(
marketplaces[0].plugins[0].interface,
+2
View File
@@ -15,6 +15,7 @@ pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
@@ -40,6 +41,7 @@ pub use manifest::PluginManifestInterface;
pub(crate) use manifest::PluginManifestPaths;
pub(crate) use manifest::load_plugin_manifest;
pub use marketplace::MarketplaceError;
pub use marketplace::MarketplaceListError;
pub use marketplace::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginPolicy;
+3 -8
View File
@@ -1,12 +1,10 @@
use crate::codex::Session;
use crate::compact::content_items_to_text;
use crate::event_mapping::is_contextual_user_message_content;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::git_info::resolve_root_git_project_for_trust_with_fs;
use crate::truncate::TruncationPolicy;
use crate::truncate::truncate_text;
use chrono::Utc;
use codex_exec_server::ExecutorFileSystem;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::models::ResponseItem;
use codex_state::SortKey;
use codex_state::ThreadMetadata;
@@ -56,13 +54,11 @@ pub(crate) async fn build_realtime_startup_context(
) -> Option<String> {
let config = sess.get_config().await;
let cwd = config.cwd.clone();
let file_system = sess.services.environment.get_filesystem();
let history = sess.clone_history().await;
let current_thread_section = build_current_thread_section(history.raw_items());
let recent_threads = load_recent_threads(sess).await;
let recent_work_section = build_recent_work_section(&cwd, &recent_threads).await;
let workspace_section =
build_workspace_section_with_user_root(file_system.as_ref(), &cwd, home_dir()).await;
let workspace_section = build_workspace_section_with_user_root(&cwd, home_dir()).await;
if current_thread_section.is_none()
&& recent_work_section.is_none()
@@ -286,11 +282,10 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option<String> {
}
async fn build_workspace_section_with_user_root(
file_system: &dyn ExecutorFileSystem,
cwd: &Path,
user_root: Option<PathBuf>,
) -> Option<String> {
let git_root = resolve_root_git_project_for_trust_with_fs(file_system, cwd).await;
let git_root = resolve_root_git_project_for_trust(cwd).await;
let cwd_tree = render_tree(cwd);
let git_root_tree = git_root
.as_ref()
+3 -4
View File
@@ -2,7 +2,6 @@ use super::build_recent_work_section;
use super::build_workspace_section_with_user_root;
use chrono::TimeZone;
use chrono::Utc;
use codex_exec_server::LOCAL_FS;
use codex_protocol::ThreadId;
use codex_state::ThreadMetadata;
use pretty_assertions::assert_eq;
@@ -48,7 +47,7 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
async fn workspace_section_requires_meaningful_structure() {
let cwd = TempDir::new().expect("tempdir");
assert_eq!(
build_workspace_section_with_user_root(&LOCAL_FS, cwd.path(), None).await,
build_workspace_section_with_user_root(cwd.path(), None).await,
None
);
}
@@ -59,7 +58,7 @@ async fn workspace_section_includes_tree_when_entries_exist() {
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
let section = build_workspace_section_with_user_root(&LOCAL_FS, cwd.path(), None)
let section = build_workspace_section_with_user_root(cwd.path(), None)
.await
.expect("workspace section");
assert!(section.contains("Working directory tree:"));
@@ -81,7 +80,7 @@ async fn workspace_section_includes_user_root_tree_when_distinct() {
fs::create_dir_all(user_root.join("code")).expect("create user root child");
fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file");
let section = build_workspace_section_with_user_root(&LOCAL_FS, cwd.as_path(), Some(user_root))
let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root))
.await
.expect("workspace section");
assert!(section.contains("User root tree:"));
+1 -1
View File
@@ -1,4 +1,4 @@
use codex_git::merge_base_with_head;
use codex_git_utils::merge_base_with_head;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use std::path::Path;
+1 -1
View File
@@ -1039,7 +1039,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
summary.git_sha = session_meta_line
.git
.as_ref()
.and_then(|git| git.commit_hash.clone());
.and_then(|git| git.commit_hash.as_ref().map(|sha| sha.0.clone()));
summary.git_origin_url = session_meta_line
.git
.as_ref()
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) fn builder_from_session_meta(
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
builder.approval_mode = AskForApproval::OnRequest;
if let Some(git) = session_meta.git.as_ref() {
builder.git_sha = git.commit_hash.clone();
builder.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone());
builder.git_branch = git.branch.clone();
builder.git_origin_url = git.repository_url.clone();
}
+1 -1
View File
@@ -245,7 +245,7 @@ async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_f
"2026-01-27T12:34:56Z",
thread_uuid,
Some(GitInfo {
commit_hash: Some("rollout-sha".to_string()),
commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")),
branch: Some("rollout-branch".to_string()),
repository_url: Some("git@example.com:openai/codex.git".to_string()),
}),
+7 -2
View File
@@ -41,13 +41,14 @@ use super::policy::EventPersistenceMode;
use super::policy::is_persisted_response_item;
use crate::config::Config;
use crate::default_client::originator;
use crate::git_info::collect_git_info;
use crate::path_utils;
use crate::state_db;
use crate::state_db::StateDbHandle;
use crate::truncate::TruncationPolicy;
use crate::truncate::truncate_text;
use codex_git_utils::collect_git_info;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo as ProtocolGitInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
@@ -846,7 +847,11 @@ async fn write_session_meta(
default_provider: &str,
generate_memories: bool,
) -> std::io::Result<()> {
let git_info = collect_git_info(cwd).await;
let git_info = collect_git_info(cwd).await.map(|info| ProtocolGitInfo {
commit_hash: info.commit_hash,
branch: info.branch,
repository_url: info.repository_url,
});
let session_meta_line = SessionMetaLine {
meta: session_meta,
git: git_info,
+116
View File
@@ -0,0 +1,116 @@
//! Skills-specific watcher built on top of the generic [`FileWatcher`].
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::runtime::Handle;
use tokio::sync::broadcast;
use tracing::warn;
use crate::config::Config;
use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherSubscriber;
use crate::file_watcher::Receiver;
use crate::file_watcher::ThrottledWatchReceiver;
use crate::file_watcher::WatchPath;
use crate::file_watcher::WatchRegistration;
use crate::skills::SkillsManager;
#[cfg(not(test))]
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
#[cfg(test)]
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillsWatcherEvent {
SkillsChanged { paths: Vec<PathBuf> },
}
pub(crate) struct SkillsWatcher {
subscriber: FileWatcherSubscriber,
tx: broadcast::Sender<SkillsWatcherEvent>,
}
impl SkillsWatcher {
pub(crate) fn new(file_watcher: &Arc<FileWatcher>) -> Self {
let (subscriber, rx) = file_watcher.add_subscriber();
let (tx, _) = broadcast::channel(128);
let skills_watcher = Self {
subscriber,
tx: tx.clone(),
};
Self::spawn_event_loop(rx, tx);
skills_watcher
}
pub(crate) fn noop() -> Self {
Self::new(&Arc::new(FileWatcher::noop()))
}
pub(crate) fn subscribe(&self) -> broadcast::Receiver<SkillsWatcherEvent> {
self.tx.subscribe()
}
pub(crate) fn register_config(
&self,
config: &Config,
skills_manager: &SkillsManager,
) -> WatchRegistration {
let roots = skills_manager
.skill_roots_for_config(config)
.into_iter()
.map(|root| WatchPath {
path: root.path,
recursive: true,
})
.collect();
self.subscriber.register_paths(roots)
}
fn spawn_event_loop(rx: Receiver, tx: broadcast::Sender<SkillsWatcherEvent>) {
let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL);
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
while let Some(event) = rx.recv().await {
let _ = tx.send(SkillsWatcherEvent::SkillsChanged { paths: event.paths });
}
});
} else {
warn!("skills watcher listener skipped: no Tokio runtime available");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tokio::time::Duration;
use tokio::time::timeout;
#[tokio::test]
async fn forwards_file_watcher_events() {
let file_watcher = Arc::new(FileWatcher::noop());
let skills_watcher = SkillsWatcher::new(&file_watcher);
let mut rx = skills_watcher.subscribe();
let _registration = skills_watcher
.subscriber
.register_path(PathBuf::from("/tmp/skill"), true);
file_watcher
.send_paths_for_test(vec![PathBuf::from("/tmp/skill/SKILL.md")])
.await;
let event = timeout(Duration::from_secs(2), rx.recv())
.await
.expect("skills watcher event")
.expect("broadcast recv");
assert_eq!(
event,
SkillsWatcherEvent::SkillsChanged {
paths: vec![PathBuf::from("/tmp/skill/SKILL.md")],
}
);
}
}
+2 -2
View File
@@ -8,12 +8,12 @@ use crate::analytics_client::AnalyticsEventsClient;
use crate::client::ModelClient;
use crate::config::StartedNetworkProxy;
use crate::exec_policy::ExecPolicyManager;
use crate::file_watcher::FileWatcher;
use crate::mcp::McpManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::plugins::PluginsManager;
use crate::skills::SkillsManager;
use crate::skills_watcher::SkillsWatcher;
use crate::state_db::StateDbHandle;
use crate::tools::code_mode::CodeModeService;
use crate::tools::network_approval::NetworkApprovalService;
@@ -54,7 +54,7 @@ pub(crate) struct SessionServices {
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) file_watcher: Arc<FileWatcher>,
pub(crate) skills_watcher: Arc<SkillsWatcher>,
pub(crate) agent_control: AgentControl,
pub(crate) network_proxy: Option<StartedNetworkProxy>,
pub(crate) network_approval: Arc<NetworkApprovalService>,
+4 -4
View File
@@ -5,10 +5,10 @@ use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use async_trait::async_trait;
use codex_git::CreateGhostCommitOptions;
use codex_git::GhostSnapshotReport;
use codex_git::GitToolingError;
use codex_git::create_ghost_commit_with_report;
use codex_git_utils::CreateGhostCommitOptions;
use codex_git_utils::GhostSnapshotReport;
use codex_git_utils::GitToolingError;
use codex_git_utils::create_ghost_commit_with_report;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use codex_utils_readiness::Readiness;
@@ -1,5 +1,5 @@
use super::*;
use codex_git::LargeUntrackedDir;
use codex_git_utils::LargeUntrackedDir;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
+17 -25
View File
@@ -30,7 +30,6 @@ use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::models_manager::manager::ModelsManager;
use crate::protocol::EventMsg;
use crate::protocol::TokenUsage;
use crate::protocol::TurnAbortReason;
use crate::protocol::TurnAbortedEvent;
use crate::protocol::TurnCompleteEvent;
@@ -192,11 +191,6 @@ impl Session {
let cancellation_token = CancellationToken::new();
let done = Arc::new(Notify::new());
let timer = turn_context
.session_telemetry
.start_timer(TURN_E2E_DURATION_METRIC, &[])
.ok();
let done_clone = Arc::clone(&done);
let handle = {
let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self)));
@@ -236,6 +230,21 @@ impl Session {
)
};
let queued_response_items = self.take_queued_response_items_for_next_turn().await;
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
let mut turn_state = turn.turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
for item in queued_response_items {
turn_state.push_pending_input(item);
}
drop(turn_state);
let timer = turn_context
.session_telemetry
.start_timer(TURN_E2E_DURATION_METRIC, &[])
.ok();
let running_task = RunningTask {
done,
handle: Arc::new(AbortOnDropHandle::new(handle)),
@@ -245,8 +254,8 @@ impl Session {
turn_context: Arc::clone(&turn_context),
_timer: timer,
};
self.register_new_active_task(running_task, token_usage_at_turn_start)
.await;
turn.add_task(running_task);
*active = Some(turn);
}
pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc<Self>) {
@@ -406,23 +415,6 @@ impl Session {
self.send_event(turn_context.as_ref(), event).await;
}
async fn register_new_active_task(
&self,
task: RunningTask,
token_usage_at_turn_start: TokenUsage,
) {
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
let mut turn_state = turn.turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
for item in self.take_queued_response_items_for_next_turn().await {
turn_state.push_pending_input(item);
}
drop(turn_state);
turn.add_task(task);
*active = Some(turn);
}
async fn take_active_turn(&self) -> Option<ActiveTurn> {
let mut active = self.active_turn.lock().await;
active.take()
+17 -9
View File
@@ -63,14 +63,22 @@ impl SessionTask for RegularTask {
Some(*prewarmed_client_session)
}
};
run_turn(
sess,
ctx,
input,
prewarmed_client_session,
cancellation_token,
)
.instrument(run_turn_span)
.await
let mut next_input = input;
let mut prewarmed_client_session = prewarmed_client_session;
loop {
let last_agent_message = run_turn(
Arc::clone(&sess),
Arc::clone(&ctx),
next_input,
prewarmed_client_session.take(),
cancellation_token.child_token(),
)
.instrument(run_turn_span.clone())
.await;
if !sess.has_pending_input().await {
return last_agent_message;
}
next_input = Vec::new();
}
}
}
+2 -2
View File
@@ -8,8 +8,8 @@ use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use async_trait::async_trait;
use codex_git::RestoreGhostCommitOptions;
use codex_git::restore_ghost_commit_with_options;
use codex_git_utils::RestoreGhostCommitOptions;
use codex_git_utils::restore_ghost_commit_with_options;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
+18 -20
View File
@@ -12,7 +12,6 @@ use crate::config::Config;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherEvent;
use crate::mcp::McpManager;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::manager::ModelsManager;
@@ -24,6 +23,8 @@ use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillsManager;
use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::tasks::interrupted_turn_history_marker;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
@@ -83,32 +84,33 @@ impl Drop for TempCodexHomeGuard {
}
}
fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc<SkillsManager>) -> Arc<FileWatcher> {
fn build_skills_watcher(skills_manager: Arc<SkillsManager>) -> Arc<SkillsWatcher> {
if should_use_test_thread_manager_behavior()
&& let Ok(handle) = Handle::try_current()
&& handle.runtime_flavor() == RuntimeFlavor::CurrentThread
{
// The real watcher spins background tasks that can starve the
// current-thread test runtime and cause event waits to time out.
warn!("using noop file watcher under current-thread test runtime");
return Arc::new(FileWatcher::noop());
warn!("using noop skills watcher under current-thread test runtime");
return Arc::new(SkillsWatcher::noop());
}
let file_watcher = match FileWatcher::new(codex_home) {
let file_watcher = match FileWatcher::new() {
Ok(file_watcher) => Arc::new(file_watcher),
Err(err) => {
warn!("failed to initialize file watcher: {err}");
Arc::new(FileWatcher::noop())
}
};
let skills_watcher = Arc::new(SkillsWatcher::new(&file_watcher));
let mut rx = file_watcher.subscribe();
let mut rx = skills_watcher.subscribe();
let skills_manager = Arc::clone(&skills_manager);
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
loop {
match rx.recv().await {
Ok(FileWatcherEvent::SkillsChanged { .. }) => {
Ok(SkillsWatcherEvent::SkillsChanged { .. }) => {
skills_manager.clear_cache();
}
Err(broadcast::error::RecvError::Closed) => break,
@@ -117,10 +119,10 @@ fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc<SkillsManager>) -
}
});
} else {
warn!("file watcher listener skipped: no Tokio runtime available");
warn!("skills watcher listener skipped: no Tokio runtime available");
}
file_watcher
skills_watcher
}
/// Represents a newly created Codex thread (formerly called a conversation), including the first event
@@ -201,7 +203,7 @@ pub(crate) struct ThreadManagerState {
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
file_watcher: Arc<FileWatcher>,
skills_watcher: Arc<SkillsWatcher>,
session_source: SessionSource,
// Captures submitted ops for testing purpose when test mode is enabled.
ops_log: Option<SharedCapturedOps>,
@@ -233,7 +235,7 @@ impl ThreadManager {
config.bundled_skills_enabled(),
restriction_product,
));
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
@@ -248,7 +250,7 @@ impl ThreadManager {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
auth_manager,
session_source,
ops_log: should_use_test_thread_manager_behavior()
@@ -299,7 +301,7 @@ impl ThreadManager {
/*bundled_skills_enabled*/ true,
restriction_product,
));
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
@@ -312,7 +314,7 @@ impl ThreadManager {
skills_manager,
plugins_manager,
mcp_manager,
file_watcher,
skills_watcher,
auth_manager,
session_source: SessionSource::Exec,
ops_log: should_use_test_thread_manager_behavior()
@@ -342,10 +344,6 @@ impl ThreadManager {
self.state.mcp_manager.clone()
}
pub fn subscribe_file_watcher(&self) -> broadcast::Receiver<FileWatcherEvent> {
self.state.file_watcher.subscribe()
}
pub fn get_models_manager(&self) -> Arc<ModelsManager> {
self.state.models_manager.clone()
}
@@ -838,7 +836,7 @@ impl ThreadManagerState {
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let watch_registration = self
.file_watcher
.skills_watcher
.register_config(&config, self.skills_manager.as_ref());
let CodexSpawnOk {
codex, thread_id, ..
@@ -849,7 +847,7 @@ impl ThreadManagerState {
skills_manager: Arc::clone(&self.skills_manager),
plugins_manager: Arc::clone(&self.plugins_manager),
mcp_manager: Arc::clone(&self.mcp_manager),
file_watcher: Arc::clone(&self.file_watcher),
skills_watcher: Arc::clone(&self.skills_watcher),
conversation_history: initial_history,
session_source,
agent_control,
@@ -21,8 +21,9 @@ use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::turn_diff_tracker::TurnDiffTracker;
@@ -337,7 +338,7 @@ async fn spawn_agent_errors_when_manager_dropped() {
}
#[tokio::test]
async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path() {
async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_path() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
@@ -400,18 +401,18 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
Some("/root/test_process")
);
SendInputHandlerV2
SendMessageHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"send_input",
"send_message",
function_payload(json!({
"target": "test_process",
"message": "continue"
"items": [{"type": "text", "text": "continue"}]
})),
))
.await
.expect("send_input should accept v2 path");
.expect("send_message should accept v2 path");
assert!(manager.captured_ops().iter().any(|(id, op)| {
*id == child_thread_id
@@ -422,6 +423,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
&& communication.recipient.as_str() == "/root/test_process"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& !communication.trigger_turn
)
}));
}
@@ -647,7 +649,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
}
#[tokio::test]
async fn multi_agent_v2_send_input_accepts_structured_items() {
async fn multi_agent_v2_send_message_rejects_structured_items() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -683,7 +685,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
let invocation = invocation(
session,
turn,
"send_input",
"send_message",
function_payload(json!({
"target": agent_id.to_string(),
"items": [
@@ -693,33 +695,19 @@ async fn multi_agent_v2_send_input_accepts_structured_items() {
})),
);
SendInputHandlerV2
.handle(invocation)
.await
.expect("structured items should be accepted in v2");
let expected = Op::UserInput {
items: vec![
UserInput::Mention {
name: "drive".to_string(),
path: "app://google_drive".to_string(),
},
UserInput::Text {
text: "read the folder".to_string(),
text_elements: Vec::new(),
},
],
final_output_json_schema: None,
let Err(err) = SendMessageHandlerV2.handle(invocation).await else {
panic!("structured items should be rejected in v2");
};
let captured = manager
.captured_ops()
.into_iter()
.find(|(id, op)| *id == agent_id && *op == expected);
assert_eq!(captured, Some((agent_id, expected)));
assert_eq!(
err,
FunctionCallError::RespondToModel(
"send_message only supports text content in MultiAgentV2 for now".to_string()
)
);
}
#[tokio::test]
async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() {
async fn multi_agent_v2_send_message_interrupts_busy_child_without_triggering_turn() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -771,19 +759,161 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
)
.await;
SendInputHandlerV2
SendMessageHandlerV2
.handle(invocation(
session,
turn,
"send_input",
session.clone(),
turn.clone(),
"send_message",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"items": [{"type": "text", "text": "continue"}],
"interrupt": true
})),
))
.await
.expect("interrupting v2 send_input should succeed");
.expect("interrupting v2 send_message should succeed");
let ops = manager.captured_ops();
let ops_for_agent: Vec<&Op> = ops
.iter()
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
.collect();
assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt)));
assert!(ops_for_agent.iter().any(|op| {
matches!(
op,
Op::InterAgentCommunication { communication }
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/worker"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
&& !communication.trigger_turn
)
}));
timeout(Duration::from_secs(5), async {
loop {
if !thread
.codex
.session
.has_queued_response_items_for_next_turn()
.await
{
tokio::time::sleep(Duration::from_millis(10)).await;
continue;
}
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let saw_envelope = history_contains_inter_agent_communication(
&history_items,
&InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"continue".to_string(),
false,
),
);
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } if text == "continue"
))
)
});
if saw_envelope && !saw_user_message {
panic!("send_message should not materialize the envelope into history");
}
if !saw_user_message {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("interrupting v2 send_message should queue the redirected message without a turn");
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "boot worker",
"task_name": "worker"
})),
))
.await
.expect("spawn worker");
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker should resolve");
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
thread
.codex
.session
.spawn_task(
Arc::clone(&active_turn),
vec![UserInput::Text {
text: "working".to_string(),
text_elements: Vec::new(),
}],
NeverEndingTask,
)
.await;
AssignTaskHandlerV2
.handle(invocation(
session,
turn,
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"interrupt": true
})),
))
.await
.expect("interrupting v2 assign_task should succeed");
let ops = manager.captured_ops();
let ops_for_agent: Vec<&Op> = ops
@@ -801,14 +931,6 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
&& communication.content == "continue"
)
}));
assert!(!ops_for_agent.iter().any(|op| matches!(
op,
Op::UserInput { items, .. }
if items.iter().any(|item| matches!(
item,
UserInput::Text { text, .. } if text == "continue"
))
)));
timeout(Duration::from_secs(5), async {
loop {
@@ -826,6 +948,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"continue".to_string(),
true,
),
);
let saw_user_message = history_items.iter().any(|item| {
@@ -846,7 +969,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message(
}
})
.await
.expect("interrupting v2 send_input should preserve the redirected message");
.expect("interrupting v2 assign_task should preserve the redirected message");
let _ = thread
.submit(Op::Shutdown {})
@@ -30,12 +30,15 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
pub(crate) use assign_task::Handler as AssignTaskHandler;
pub(crate) use list_agents::Handler as ListAgentsHandler;
pub(crate) use send_input::Handler as SendInputHandler;
pub(crate) use send_message::Handler as SendMessageHandler;
pub(crate) use spawn::Handler as SpawnAgentHandler;
pub(crate) use wait::Handler as WaitAgentHandler;
mod assign_task;
mod list_agents;
mod send_input;
mod message_tool;
mod send_message;
mod spawn;
pub(crate) mod wait;
@@ -0,0 +1,23 @@
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::handle_message_tool;
use super::*;
pub(crate) struct Handler;
#[async_trait]
impl ToolHandler for Handler {
type Output = MessageToolResult;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
handle_message_tool(invocation, MessageDeliveryMode::TriggerTurn).await
}
}
@@ -0,0 +1,175 @@
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
//!
//! `send_message` and `assign_task` intentionally expose the same input shape and differ only in
//! whether the resulting `InterAgentCommunication` should wake the target immediately.
use super::*;
use codex_protocol::protocol::InterAgentCommunication;
#[derive(Clone, Copy)]
pub(crate) enum MessageDeliveryMode {
QueueOnly,
TriggerTurn,
}
impl MessageDeliveryMode {
/// Returns the model-visible error message for non-text inputs.
fn unsupported_items_error(self) -> &'static str {
match self {
Self::QueueOnly => "send_message only supports text content in MultiAgentV2 for now",
Self::TriggerTurn => "assign_task only supports text content in MultiAgentV2 for now",
}
}
/// Returns whether the produced communication should start a turn immediately.
fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication {
match self {
Self::QueueOnly => InterAgentCommunication {
trigger_turn: false,
..communication
},
Self::TriggerTurn => InterAgentCommunication {
trigger_turn: true,
..communication
},
}
}
}
#[derive(Debug, Deserialize)]
/// Input shared by the MultiAgentV2 `send_message` and `assign_task` tools.
pub(crate) struct MessageToolArgs {
pub(crate) target: String,
pub(crate) items: Vec<UserInput>,
#[serde(default)]
pub(crate) interrupt: bool,
}
#[derive(Debug, Serialize)]
/// Tool result shared by the MultiAgentV2 message-delivery tools.
pub(crate) struct MessageToolResult {
submission_id: String,
}
impl ToolOutput for MessageToolResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "multi_agent_message")
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
tool_output_response_item(call_id, payload, self, Some(true), "multi_agent_message")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "multi_agent_message")
}
}
/// Validates that the tool input is non-empty text-only content and returns its preview string.
fn text_content(
items: &[UserInput],
mode: MessageDeliveryMode,
) -> Result<String, FunctionCallError> {
if items.is_empty() {
return Err(FunctionCallError::RespondToModel(
"Items can't be empty".to_string(),
));
}
if items
.iter()
.all(|item| matches!(item, UserInput::Text { .. }))
{
return Ok(input_preview(items));
}
Err(FunctionCallError::RespondToModel(
mode.unsupported_items_error().to_string(),
))
}
/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`.
pub(crate) async fn handle_message_tool(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
) -> Result<MessageToolResult, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
call_id,
..
} = invocation;
let arguments = function_arguments(payload)?;
let args: MessageToolArgs = parse_arguments(&arguments)?;
let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?;
let prompt = text_content(&args.items, mode)?;
let receiver_agent = session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
if args.interrupt {
session
.services
.agent_control
.interrupt_agent(receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
session
.send_event(
&turn,
CollabAgentInteractionBeginEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id,
prompt: prompt.clone(),
}
.into(),
)
.await;
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
})?;
let communication = InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
/*trigger_turn*/ true,
);
let result = session
.services
.agent_control
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication))
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err));
let status = session
.services
.agent_control
.get_status(receiver_thread_id)
.await;
session
.send_event(
&turn,
CollabAgentInteractionEndEvent {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id,
receiver_agent_nickname: receiver_agent.agent_nickname,
receiver_agent_role: receiver_agent.agent_role,
prompt,
status,
}
.into(),
)
.await;
let submission_id = result?;
Ok(MessageToolResult { submission_id })
}
@@ -1,145 +0,0 @@
use super::*;
use codex_protocol::protocol::InterAgentCommunication;
pub(crate) struct Handler;
fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool {
items
.iter()
.all(|item| matches!(item, UserInput::Text { .. }))
}
#[async_trait]
impl ToolHandler for Handler {
type Output = SendInputResult;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
call_id,
..
} = invocation;
let arguments = function_arguments(payload)?;
let args: SendInputArgs = parse_arguments(&arguments)?;
let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?;
let input_items = parse_collab_input(args.message, args.items)?;
let prompt = input_preview(&input_items);
let receiver_agent = session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
if args.interrupt {
session
.services
.agent_control
.interrupt_agent(receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
session
.send_event(
&turn,
CollabAgentInteractionBeginEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id,
prompt: prompt.clone(),
}
.into(),
)
.await;
let result = if can_use_v2_inter_agent_instruction(&input_items) {
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
FunctionCallError::RespondToModel(
"target agent is missing an agent_path".to_string(),
)
})?;
let communication = InterAgentCommunication::new(
turn.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
receiver_agent_path,
Vec::new(),
prompt.clone(),
);
session
.services
.agent_control
.send_inter_agent_communication(receiver_thread_id, communication)
.await
} else {
session
.services
.agent_control
.send_input(receiver_thread_id, input_items)
.await
}
.map_err(|err| collab_agent_error(receiver_thread_id, err));
let status = session
.services
.agent_control
.get_status(receiver_thread_id)
.await;
session
.send_event(
&turn,
CollabAgentInteractionEndEvent {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id,
receiver_agent_nickname: receiver_agent.agent_nickname,
receiver_agent_role: receiver_agent.agent_role,
prompt,
status,
}
.into(),
)
.await;
let submission_id = result?;
Ok(SendInputResult { submission_id })
}
}
#[derive(Debug, Deserialize)]
struct SendInputArgs {
target: String,
message: Option<String>,
items: Option<Vec<UserInput>>,
#[serde(default)]
interrupt: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct SendInputResult {
submission_id: String,
}
impl ToolOutput for SendInputResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "send_input")
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
tool_output_response_item(call_id, payload, self, Some(true), "send_input")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "send_input")
}
}
@@ -0,0 +1,23 @@
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::handle_message_tool;
use super::*;
pub(crate) struct Handler;
#[async_trait]
impl ToolHandler for Handler {
type Output = MessageToolResult;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
handle_message_tool(invocation, MessageDeliveryMode::QueueOnly).await
}
}
@@ -310,7 +310,7 @@ fn verified_plugin_suggestion_completed(
.list_marketplaces_for_config(config, &[])
.ok()
.into_iter()
.flatten()
.flat_map(|outcome| outcome.marketplaces)
.flat_map(|marketplace| marketplace.plugins.into_iter())
.any(|plugin| plugin.id == tool_id && plugin.installed)
}
@@ -889,7 +889,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
let mut exec_env = self.env.clone();
// `env_overlay` comes from `EscalationSession::env()`, so merge only the
// wrapper/socket variables into the base shell environment.
for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER", "BASH_EXEC_WRAPPER"] {
for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER"] {
if let Some(value) = env_overlay.get(var) {
exec_env.insert(var.to_string(), value.clone());
}
+95 -4
View File
@@ -389,7 +389,10 @@ impl ToolsConfig {
let include_default_mode_request_user_input =
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool = model_info.supports_search_tool;
let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest);
let include_tool_suggest = include_search_tool
&& features.enabled(Feature::ToolSuggest)
&& features.enabled(Feature::Apps)
&& features.enabled(Feature::Plugins);
let include_original_image_detail = can_request_original_image_detail(features, model_info);
let include_artifact_tools =
features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime();
@@ -1448,6 +1451,80 @@ fn create_send_input_tool() -> ToolSpec {
})
}
fn create_send_message_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
fn create_assign_task_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "assign_task".to_string(),
description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
fn create_resume_agent_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
@@ -2695,8 +2772,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::multi_agents::SendInputHandler;
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
use crate::tools::handlers::multi_agents::WaitAgentHandler;
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use std::sync::Arc;
@@ -3085,10 +3163,22 @@ pub(crate) fn build_specs_with_discoverable_tools(
);
push_tool_spec(
&mut builder,
create_send_input_tool(),
if config.multi_agent_v2 {
create_send_message_tool()
} else {
create_send_input_tool()
},
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
if config.multi_agent_v2 {
push_tool_spec(
&mut builder,
create_assign_task_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
if !config.multi_agent_v2 {
push_tool_spec(
&mut builder,
@@ -3122,7 +3212,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
config.code_mode_enabled,
);
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2));
builder.register_handler("send_input", Arc::new(SendInputHandlerV2));
builder.register_handler("send_message", Arc::new(SendMessageHandlerV2));
builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2));
builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2));
builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2));
} else {
+78 -6
View File
@@ -550,7 +550,8 @@ async fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
&tools,
&[
"spawn_agent",
"send_input",
"send_message",
"assign_task",
"wait_agent",
"close_agent",
"list_agents",
@@ -584,9 +585,9 @@ async fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
json!(["agent_id", "task_name", "nickname"])
);
let send_input = find_tool(&tools, "send_input");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_input.spec else {
panic!("send_input should be a function tool");
let send_message = find_tool(&tools, "send_message");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else {
panic!("send_message should be a function tool");
};
let JsonSchema::Object {
properties,
@@ -594,10 +595,33 @@ async fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
..
} = parameters
else {
panic!("send_input should use object params");
panic!("send_message should use object params");
};
assert!(properties.contains_key("target"));
assert_eq!(required.as_ref(), Some(&vec!["target".to_string()]));
assert!(!properties.contains_key("message"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
);
let assign_task = find_tool(&tools, "assign_task");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &assign_task.spec else {
panic!("assign_task should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("assign_task should use object params");
};
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("message"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
);
let wait_agent = find_tool(&tools, "wait_agent");
let ToolSpec::Function(ResponsesApiTool {
@@ -652,6 +676,7 @@ async fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
output_schema["properties"]["agents"]["items"]["required"],
json!(["agent_name", "agent_status", "last_task_message"])
);
assert_lacks_tool_name(&tools, "send_input");
assert_lacks_tool_name(&tools, "resume_agent");
}
@@ -2015,6 +2040,7 @@ async fn tool_suggest_is_not_registered_without_feature_flag() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -2045,6 +2071,51 @@ async fn tool_suggest_is_not_registered_without_feature_flag() {
);
}
#[tokio::test]
async fn tool_suggest_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info().await;
let discoverable_tools = Some(vec![discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
"Plan events and schedules.",
)]);
let available_models = Vec::new();
for disabled_feature in [Feature::Apps, Feature::Plugins] {
let mut features = Features::with_defaults();
features.enable(Feature::ToolSuggest);
for feature in [Feature::Apps, Feature::Plugins] {
if feature != disabled_feature {
features.enable(feature);
}
}
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs_with_discoverable_tools(
&tools_config,
None,
None,
discoverable_tools.clone(),
&[],
)
.build();
assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME),
"tool_suggest should be absent when {disabled_feature:?} is disabled"
);
}
}
#[tokio::test]
async fn search_tool_description_handles_no_enabled_apps() {
let model_info = search_capable_model_info().await;
@@ -2189,6 +2260,7 @@ async fn tool_suggest_description_lists_discoverable_tools() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
features.enable(Feature::ToolSuggest);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
+8 -6
View File
@@ -8,11 +8,11 @@ use std::sync::RwLock;
use serde::Serialize;
use tokio::task::JoinHandle;
use crate::git_info::get_git_remote_urls_assume_git_repo;
use crate::git_info::get_git_repo_root;
use crate::git_info::get_has_changes;
use crate::git_info::get_head_commit_hash;
use crate::sandbox_tags::sandbox_tag;
use codex_git_utils::get_git_remote_urls_assume_git_repo;
use codex_git_utils::get_git_repo_root;
use codex_git_utils::get_has_changes;
use codex_git_utils::get_head_commit_hash;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::SandboxPolicy;
@@ -94,11 +94,12 @@ fn build_turn_metadata_bag(
pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Option<String> {
let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned());
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(cwd),
get_git_remote_urls_assume_git_repo(cwd),
get_has_changes(cwd),
);
let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0);
if latest_git_commit_hash.is_none()
&& associated_remote_urls.is_none()
&& has_changes.is_none()
@@ -231,11 +232,12 @@ impl TurnMetadataState {
}
async fn fetch_workspace_git_metadata(&self) -> WorkspaceGitMetadata {
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(&self.cwd),
get_git_remote_urls_assume_git_repo(&self.cwd),
get_has_changes(&self.cwd),
);
let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0);
WorkspaceGitMetadata {
associated_remote_urls,
+166 -10
View File
@@ -294,13 +294,14 @@ async fn returns_fresh_tokens_as_is() -> Result<()> {
.await;
let ctx = RefreshTokenTestContext::new(&server)?;
let initial_last_refresh = Utc::now() - Duration::days(1);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let stale_refresh = Utc::now() - Duration::days(9);
let fresh_access_token = access_token_with_expiration(Utc::now() + Duration::hours(1));
let initial_tokens = build_tokens(&fresh_access_token, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
last_refresh: Some(stale_refresh),
};
ctx.write_auth(&initial_auth)?;
@@ -325,7 +326,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> {
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
async fn refreshes_token_when_access_token_is_expired() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
@@ -340,13 +341,14 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
.await;
let ctx = RefreshTokenTestContext::new(&server)?;
let stale_refresh = Utc::now() - Duration::days(9);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let fresh_refresh = Utc::now() - Duration::days(1);
let expired_access_token = access_token_with_expiration(Utc::now() - Duration::hours(1));
let initial_tokens = build_tokens(&expired_access_token, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(stale_refresh),
last_refresh: Some(fresh_refresh),
};
ctx.write_auth(&initial_auth)?;
@@ -373,7 +375,7 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
.as_ref()
.context("last_refresh should be recorded")?;
assert!(
*refreshed_at >= stale_refresh,
*refreshed_at >= fresh_refresh,
"last_refresh should advance"
);
@@ -543,6 +545,153 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {
"code": "refresh_token_reused"
}
})))
.expect(1)
.mount(&server)
.await;
let ctx = RefreshTokenTestContext::new(&server)?;
let initial_last_refresh = Utc::now() - Duration::days(1);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
};
ctx.write_auth(&initial_auth)?;
let first_err = ctx
.auth_manager
.refresh_token()
.await
.err()
.context("first refresh should fail")?;
assert_eq!(
first_err.failed_reason(),
Some(RefreshTokenFailedReason::Exhausted)
);
let second_err = ctx
.auth_manager
.refresh_token()
.await
.err()
.context("second refresh should fail without retrying")?;
assert_eq!(
second_err.failed_reason(),
Some(RefreshTokenFailedReason::Exhausted)
);
let stored = ctx.load_auth()?;
assert_eq!(stored, initial_auth);
let cached_auth = ctx
.auth_manager
.auth()
.await
.context("auth should remain cached")?;
let cached = cached_auth
.get_token_data()
.context("token data should remain cached")?;
assert_eq!(cached, initial_tokens);
server.verify().await;
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {
"code": "refresh_token_reused"
}
})))
.expect(1)
.mount(&server)
.await;
let ctx = RefreshTokenTestContext::new(&server)?;
let initial_last_refresh = Utc::now() - Duration::days(1);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens.clone()),
last_refresh: Some(initial_last_refresh),
};
ctx.write_auth(&initial_auth)?;
let first_err = ctx
.auth_manager
.refresh_token()
.await
.err()
.context("first refresh should fail")?;
assert_eq!(
first_err.failed_reason(),
Some(RefreshTokenFailedReason::Exhausted)
);
let fresh_refresh = Utc::now() - Duration::hours(1);
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
let disk_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(disk_tokens.clone()),
last_refresh: Some(fresh_refresh),
};
save_auth(
ctx.codex_home.path(),
&disk_auth,
AuthCredentialsStoreMode::File,
)?;
ctx.auth_manager
.refresh_token()
.await
.context("refresh should reload changed auth without retrying")?;
let stored = ctx.load_auth()?;
assert_eq!(stored, disk_auth);
let cached_auth = ctx
.auth_manager
.auth_cached()
.context("auth should be cached")?;
let cached = cached_auth
.get_token_data()
.context("token data should reload from disk")?;
assert_eq!(cached, disk_tokens);
let requests = server.received_requests().await.unwrap_or_default();
assert_eq!(
requests.len(),
1,
"expected only the initial refresh request"
);
server.verify().await;
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> {
@@ -867,7 +1016,7 @@ impl Drop for EnvGuard {
}
}
fn minimal_jwt() -> String {
fn jwt_with_payload(payload: serde_json::Value) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
@@ -878,7 +1027,6 @@ fn minimal_jwt() -> String {
alg: "none",
typ: "JWT",
};
let payload = json!({ "sub": "user-123" });
fn b64(data: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
@@ -898,6 +1046,14 @@ fn minimal_jwt() -> String {
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn minimal_jwt() -> String {
jwt_with_payload(json!({ "sub": "user-123" }))
}
fn access_token_with_expiration(expires_at: chrono::DateTime<Utc>) -> String {
jwt_with_payload(json!({ "sub": "user-123", "exp": expires_at.timestamp() }))
}
fn build_tokens(access_token: &str, refresh_token: &str) -> TokenData {
let id_token = IdTokenInfo {
raw_jwt: minimal_jwt(),
+3 -2
View File
@@ -1,5 +1,6 @@
use assert_cmd::Command as AssertCommand;
use codex_core::auth::CODEX_API_KEY_ENV_VAR;
use codex_git_utils::collect_git_info;
use codex_protocol::protocol::GitInfo;
use codex_utils_cargo_bin::find_resource;
use core_test_support::fs_wait;
@@ -596,7 +597,7 @@ async fn integration_git_info_unit_test() {
.unwrap();
// 3. Test git info collection directly
let git_info = codex_core::git_info::collect_git_info(&git_repo).await;
let git_info = collect_git_info(&git_repo).await;
// 4. Verify git info is present and contains expected data
assert!(git_info.is_some(), "Git info should be collected");
@@ -608,7 +609,7 @@ async fn integration_git_info_unit_test() {
git_info.commit_hash.is_some(),
"Git info should contain commit_hash"
);
let commit_hash = git_info.commit_hash.as_ref().unwrap();
let commit_hash = &git_info.commit_hash.as_ref().unwrap().0;
assert_eq!(commit_hash.len(), 40, "Commit hash should be 40 characters");
assert!(
commit_hash.chars().all(|c| c.is_ascii_hexdigit()),
@@ -310,10 +310,10 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
return Ok(());
}
// 1. Arrange mocked SSE responses for the initial flow plus the second compact.
// 1. Arrange mocked SSE responses as a single ordered stream so assertions
// observe the real request sequence instead of per-mock duplicate captures.
let server = MockServer::start().await;
let mut request_log = mount_initial_flow(&server).await;
request_log.extend(mount_second_compact_flow(&server).await);
let request_log = mount_second_compact_sequence(&server).await;
// 2. Drive the conversation through compact -> resume -> fork -> compact -> resume.
let (_home, config, manager, base) = start_test_conversation(&server, None).await;
@@ -349,7 +349,12 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
let resumed_again = resume_conversation(&manager, &config, forked_path).await;
user_turn(&resumed_again, AFTER_SECOND_RESUME).await;
let mut requests = gather_request_bodies(&request_log);
let mut requests = request_log
.requests()
.into_iter()
.map(|request| request.body_json())
.collect::<Vec<_>>();
requests.iter_mut().for_each(normalize_line_endings);
normalize_compact_prompts(&mut requests);
let input_after_compact = json!(requests[requests.len() - 2]["input"]);
let input_after_resume = json!(requests[requests.len() - 1]["input"]);
@@ -382,20 +387,18 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
);
let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index];
let summary_after_second_compact =
extract_summary_user_text(&requests[requests.len() - 3], SUMMARY_TEXT);
extract_summary_user_text(&requests[requests.len() - 2], SUMMARY_TEXT);
let mut expected_after_second_compact_user_texts = vec![
"hello world".to_string(),
"AFTER_COMPACT".to_string(),
"AFTER_RESUME".to_string(),
"AFTER_FORK".to_string(),
summary_after_second_compact,
summary_after_second_compact.clone(),
];
expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix);
expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string());
let mut expected_fork_local_user_texts = vec![
"AFTER_FORK".to_string(),
expected_after_second_compact_user_texts[4].clone(),
];
let mut expected_fork_local_user_texts =
vec!["AFTER_FORK".to_string(), summary_after_second_compact];
expected_fork_local_user_texts.extend_from_slice(seeded_user_prefix);
expected_fork_local_user_texts.push("AFTER_COMPACT_2".to_string());
let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user");
@@ -403,11 +406,16 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
.split_last()
.unwrap_or_else(|| panic!("after-second-resume request missing user messages"));
assert_eq!(final_last, AFTER_SECOND_RESUME);
let matched_prefix_len = if final_prefix.starts_with(&expected_after_second_compact_user_texts)
let matched_prefix_len = if let Some(start) = final_prefix
.windows(expected_after_second_compact_user_texts.len())
.position(|window| window == expected_after_second_compact_user_texts)
{
expected_after_second_compact_user_texts.len()
} else if final_prefix.starts_with(&expected_fork_local_user_texts) {
expected_fork_local_user_texts.len()
start + expected_after_second_compact_user_texts.len()
} else if let Some(start) = final_prefix
.windows(expected_fork_local_user_texts.len())
.position(|window| window == expected_fork_local_user_texts)
{
start + expected_fork_local_user_texts.len()
} else {
panic!("after-second-resume user texts should preserve post-compact user history prefix");
};
@@ -522,8 +530,9 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
/// Scenario: rolling back a turn that introduced persistent pre-turn context
/// diffs currently duplicates those context updates on the next request.
async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<()> {
/// diffs should trim those context updates so the next request includes them
/// only once.
async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> {
if network_disabled() {
println!("Skipping test because network is disabled in this sandbox");
return Ok(());
@@ -602,14 +611,12 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
let requests = request_log.requests();
assert_eq!(requests.len(), 3);
assert_eq!(
requests[1]
.message_input_texts("developer")
.iter()
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
.count(),
1
);
let before_rollback_developer_count = requests[1]
.message_input_texts("developer")
.iter()
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
.count();
assert_eq!(before_rollback_developer_count, 1);
assert_eq!(
requests[1]
.message_input_texts("user")
@@ -618,14 +625,13 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
.count(),
1
);
assert_eq!(
requests[2]
.message_input_texts("developer")
.iter()
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
.count(),
2
);
let after_rollback_developer_count = requests[2]
.message_input_texts("developer")
.iter()
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
.count();
assert_eq!(after_rollback_developer_count, 1);
let after_rollback_user_texts = requests[2].message_input_texts("user");
assert_eq!(
@@ -633,7 +639,7 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
.iter()
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
.count(),
2
1
);
assert_eq!(
after_rollback_user_texts.last().map(String::as_str),
@@ -641,9 +647,9 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<
);
insta::assert_snapshot!(
"rollback_followup_turn_duplicates_context_updates",
"rollback_followup_turn_trims_context_updates",
context_snapshot::format_labeled_requests_snapshot(
"rollback currently duplicates pre-turn override context updates on the follow-up request",
"rollback trims pre-turn override context updates before the follow-up request",
&[
("rolled-back turn request", &requests[1]),
("follow-up request after rollback", &requests[2]),
@@ -749,33 +755,29 @@ async fn mount_initial_flow(server: &MockServer) -> Vec<ResponseMock> {
vec![first, compact, after_compact, after_resume, after_fork]
}
async fn mount_second_compact_flow(server: &MockServer) -> Vec<ResponseMock> {
async fn mount_second_compact_sequence(server: &MockServer) -> ResponseMock {
let sse1 = sse(vec![
ev_assistant_message("m1", FIRST_REPLY),
ev_completed("r1"),
]);
let sse2 = sse(vec![
ev_assistant_message("m2", SUMMARY_TEXT),
ev_completed("r2"),
]);
let sse3 = sse(vec![
ev_assistant_message("m3", "AFTER_COMPACT_REPLY"),
ev_completed("r3"),
]);
let sse4 = sse(vec![ev_completed("r4")]);
let sse5 = sse(vec![ev_completed("r5")]);
let sse6 = sse(vec![
ev_assistant_message("m4", SUMMARY_TEXT),
ev_completed("r6"),
]);
let sse7 = sse(vec![ev_completed("r7")]);
let sse8 = sse(vec![ev_completed("r8")]);
// Keep this matcher broad enough to survive prompt-shape differences across
// platforms/config (history may include either marker text or compact prompt
// fragments), but explicitly exclude the final resume turn so these two
// one-shot mocks cannot race for the same request.
let match_second_compact = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
(body.contains("AFTER_FORK")
|| body_contains_text(body, SUMMARIZATION_PROMPT)
|| body.contains(&json_fragment(FIRST_REPLY)))
&& !body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\""))
};
let second_compact = mount_sse_once_match(server, match_second_compact, sse6).await;
let match_after_second_resume = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\""))
};
let after_second_resume = mount_sse_once_match(server, match_after_second_resume, sse7).await;
vec![second_compact, after_second_resume]
mount_sse_sequence(server, vec![sse1, sse2, sse3, sse4, sse5, sse6, sse7, sse8]).await
}
async fn start_test_conversation(
@@ -1,8 +1,8 @@
---
source: core/tests/suite/compact_resume_fork.rs
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback currently duplicates pre-turn override context updates on the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)"
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback trims pre-turn override context updates before the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)"
---
Scenario: rollback currently duplicates pre-turn override context updates on the follow-up request
Scenario: rollback trims pre-turn override context updates before the follow-up request
## rolled-back turn request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
@@ -20,6 +20,4 @@ Scenario: rollback currently duplicates pre-turn override context updates on the
03:message/assistant:turn 1 assistant
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
06:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
07:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
08:message/user:follow-up user
06:message/user:follow-up user
@@ -23,6 +23,4 @@ Scenario: rollback past compaction replay after rollback
01:message/user:<COMPACTION_SUMMARY>\nSUMMARY_ONLY_CONTEXT
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
04:message/developer:<PERMISSIONS_INSTRUCTIONS>
05:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
06:message/user:AFTER_ROLLBACK
04:message/user:AFTER_ROLLBACK

Some files were not shown because too many files have changed in this diff Show More